atelier

Catalog / Recipe

Palette resolve

A colour field anchored on the page's own tokens, in OKLCH, that plays once, ends as the ground colour exactly, and leaves.

Family
colour
Status
lab
Used for
Page loadHeroPage transitionSection intro
Size
5,005 of 5,200 bytes, gzipped
Frame
Skin
Version
envelope
layer
mount(element)

Colour as the thing that moves, in the one form that can end: it plays once and the page is its own colours afterwards. The field has no palette of its own. Its lightness is the ground’s, pushed; its hue is the accent’s, spread; so it is right on any skin whose tokens it can read, and wrong on none.

Use

import { mount } from './palette-resolve';

mount(document.body);

The element, or something above it, carries --ground, --accent and --muted. The last is the weakest text colour on the page, and it is what the guard reads.

How it moves

One number falls from 1 to 0 and scales lift and chroma together. At 0 the colour is the ground token: it survives the round trip through OKLab to the byte, and a dither of half a step cannot move a value that is already on a step. So the canvas’s removal is invisible, and the canvas is never faded out, because a half-transparent ground over the same ground comes back one step dark.

envelope Name What happens
0 Drain The whole field thins evenly to the ground. Nothing travels
1 Front A warped edge sweeps the element; plain ground behind it
2 Band A ribbon of colour crosses and leaves; ground ahead and behind
3 Converge The hues close to the accent’s family, then that drains

The guard

Body text in the ink colour is never at risk. The casualties are the tokens tuned close to the 4.5 to 1 floor: muted text and accent links drop under it at a lift of only 0.04. With guard on, the recipe reads the muted token and caps the lift so the field’s luminance stays on the right side of (Y_text + 0.05) x 4.5 - 0.05 on a light ground, or its mirror on a dark one. On the neutral skin that leaves a lift of about 0.02, which is honest: there is very little room on a near-white ground. Where the field sits in a band with no small text, turn the guard off.

Options

Option Default Meaning
envelope 0 Which envelope, as above
chroma 0.06 OKLCH chroma. 0.014 is all every hue can reach on near-white; above 0.09 reads as a toy
lift 0.05 How far lightness moves from the ground’s, in OKLab L
spread 0.18 How far the hue strays from the accent’s, in turns either way
scale 350 Size of one blob, in CSS px
warp 0.9 How far the field is folded
octaves 2 Layers of detail, 1 to 3
direction 90 Which way the front or band travels, in degrees
softness 0.12 The front’s softness, as a share of the element
band 0.28 The band’s width, as a share of the element
drift 0 Cells a second the blobs slide while it plays
dither true One step of noise on the output
guard true Cap the lift from the muted token
layer 0 0 behind the content, 1 over with multiply, 2 over with screen
seed 3 Which field
duration, arrivalOnly, maxPixelRatio, maxPixels 1100, true, 0.25, 2,500,000 As every one-shot shader recipe; the pixel ratio is a quarter on purpose

Versions

Version What it is
Resolve The defaults: a drain, guarded
Front A warped edge sweeps it off, bottom to top
Ribbon A narrow band crosses, at a low lift
Converge A wide spread of hues closes to the accent’s, then drains
Unguarded The guard off, a stronger lift and chroma, for a band with no small text

None of them has been judged in motion on a real page, and none on a dark ground: a warm accent on a dark ground makes brown, and the dark theme wants its own numbers.

Where it must not go

  • Behind small muted text or accent links with the guard off.
  • Behind charts, tables or anything read by colour.
  • Listings and card grids, where it tints the things being compared.
  • As a continuous background. The whole point of the design is that it ends.
  • Over the content (layer 1 or 2) on a page where images are evidence: it tints them for the duration.

What it does when things are missing

Without WebGL2, on a software renderer, under reduced motion or forced colours: nothing, and the page is as it was. When it ends, the canvas is removed and the context released. A missing token gets its stand-in: white ground, blue accent, and for the muted token the grey that just passes on white, so a page that has not named its weakest text gets almost no lift. The recipe stays in sRGB; a token outside sRGB is painted clipped, mid-effect only.

Learned while building it

  • Convert the tokens once. The conversion to OKLab is the same answer for every pixel; the vertex shader of the one triangle does it three times a frame and hands the fragment flat values.
  • Add to the ground’s Lab, do not replace it. A near-white ground is rarely neutral; the sweep sits on top of its own chroma, so the last frame is the ground and not a grey near it.
  • Fit to gamut by sliding to grey. Clamping channels shifts the hue; sliding toward the grey of the same lightness keeps it.
  • Margins on the moving edge. A front or band has to start wholly off one side and end wholly off the other, bend included, or the first and last frames are not what they claim.
palette-resolve.ts631 lines · 5,005 bytes gzipped · no imports
/**
 * palette-resolve
 *
 * A colour field whose origin is the page's own tokens: lightness
 * starts at the ground's and is pushed by a lift, hue is the accent's
 * plus a spread, chroma is a dial, all in OKLCH. One number falls from
 * 1 to 0 over the run and scales lift and chroma together, so the last
 * frame is the ground token to the byte, and then the canvas leaves.
 * The page ends in its own colours with nothing left running.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './palette-resolve';
 *     mount(document.body);
 *
 * It reads `--ground`, `--accent` and `--muted`, the weakest text
 * colour on the page, and by default caps its lift so that text keeps
 * 4.5 to 1 against the field. It renders at a quarter of the pixels:
 * a field this soft has no detail to lose.
 */

export interface Options {
  /** How long the run takes, in ms. Falls back to --load-duration. */
  duration: number;
  /** Canvas pixels per CSS pixel. A quarter: the field has no detail, and the browser's upscale smooths it. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Play only when the reader arrived from outside the site. */
  arrivalOnly: boolean;
  /** How it leaves: 0 drains evenly, 1 a warped front sweeps it off, 2 a band crosses and leaves, 3 the hues close to the accent's and then drain. */
  envelope: number;
  /** Colourfulness, as OKLCH chroma. 0.014 is the most every hue can reach on a near-white ground; above 0.09 reads as a toy. */
  chroma: number;
  /** How far lightness moves from the ground's, in OKLab L. Darker on a light ground, lighter on a dark one. 0 cannot move text contrast. */
  lift: number;
  /** How far the hue strays from the accent's, in turns either way. 0 is one hue; 0.5 is the whole wheel. */
  spread: number;
  /** Size of one blob, in CSS px. Never a share of the element. */
  scale: number;
  /** How far the field is folded over itself. 0 is round blobs; 1.5 is ribbons. */
  warp: number;
  /** Layers of detail, 1 to 3. More is wasted on a field this soft. */
  octaves: number;
  /** Which way the front or band travels, in degrees. 90 is bottom to top. */
  direction: number;
  /** The front's softness, as a share of the element. */
  softness: number;
  /** The band's width, as a share of the element. */
  band: number;
  /** How fast the blobs slide while it plays, in cells a second. 0 holds the shapes still under the moving envelope. */
  drift: number;
  /** One step of noise on the output, against terraces. Always on a dark ground. */
  dither: boolean;
  /** Cap the lift so the weakest text (--muted) keeps 4.5 to 1 against the field. */
  guard: boolean;
  /** Where the canvas sits: 0 behind the content, 1 over it with multiply (light grounds), 2 over it with screen (dark grounds). */
  layer: number;
  /** Which field. The same number is the same shapes. */
  seed: number;
}

export const DEFAULTS: Options = {
  duration: 1100,
  maxPixelRatio: 0.25,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  envelope: 0,
  chroma: 0.06,
  lift: 0.05,
  spread: 0.18,
  scale: 350,
  warp: 0.9,
  octaves: 2,
  direction: 90,
  softness: 0.12,
  band: 0.28,
  drift: 0,
  dither: true,
  guard: true,
  layer: 0,
  seed: 3,
};

export interface Handle {
  update(options: Partial<Options>): void;
  destroy(): void;
}

// ---- the effect -------------------------------------------------------------

// The colour science runs once per frame, not once per pixel: the
// tokens are uniforms, so their conversion is the same answer for every
// pixel, and the full-screen triangle's vertex shader does it three
// times a frame and hands the fragment shader `flat` values. L is the
// sRGB transfer to linear light, B linear to OKLab (the matrices are
// the published ones; a cube root between them), Y relative luminance.
//
// vG is the ground in OKLab. vA holds the accent's hue, the largest
// lift the weakest text allows, and whether the ground is dark. The
// guard: on a light ground the field may not go darker than the
// luminance that keeps 4.5 to 1 against the muted token,
// (Yt + 0.05) * 4.5 - 0.05; on a dark ground not lighter than
// (Yt + 0.05) / 4.5 - 0.05. A grey of OKLab lightness l has luminance
// l cubed, so the cap in L is the cube root of that luminance against
// the ground's L. With the guard off the cap is 1, which is no cap.
const VERTEX = `#version 300 es
precision highp float;
uniform vec3 uGround,uAccent,uMuted;
uniform vec4 uMore;
flat out vec3 vG;flat out vec4 vA;
vec3 L(vec3 c){return mix(c/12.92,pow((c+.055)/1.055,vec3(2.4)),step(.04045,c));}
vec3 B(vec3 c){vec3 l=mat3(.4122215,.2119035,.0883025,.5363325,.6806996,.2817188,.0514460,.1073970,.6299787)*c;
l=sign(l)*pow(abs(l),vec3(1./3.));return mat3(.2104543,1.9779985,.0259040,.7936178,-2.4285922,.7827718,-.0040720,.4505937,-.8086758)*l;}
float Y(vec3 c){return dot(c,vec3(.2126,.7152,.0722));}
void main(){
gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);
vec3 g=L(uGround),a=B(L(uAccent));vG=B(g);
float dk=step(vG.x,.5),yt=Y(L(uMuted));
float lim=dk>.5?pow(max((yt+.05)/4.5-.05,0.),1./3.)-vG.x:vG.x-pow(max((yt+.05)*4.5-.05,0.),1./3.);
vA=vec4(atan(a.z,a.y),uMore.x>.5?max(lim,0.):1.,dk,0.);
}`;

// H is an integer hash, N value noise with the quintic fade (its
// lattice cannot be seen when one cell spans a third of the element),
// F octaves of it normalised. P is OKLab to linear light, E linear to
// sRGB. Q fits a colour to the gamut by sliding it toward the grey of
// its own lightness, in linear light, just far enough that every
// channel fits: a plain clamp would shift the hue.
//
// main: q is the point in the field, drifted and warped. f and s are
// two samples of it, for hue and for lightness. d is the pixel's place
// along the direction of travel. The envelope gives k, 1 to 0, and sp,
// the share of the spread that is still open: drain is the same k
// everywhere; front is a soft step at a bent edge that crosses the
// element with margins wide enough that k is 1 everywhere at the start
// and 0 everywhere at the end; band is a Gaussian on the same edge;
// converge closes the spread first, then drains. The colour is the
// ground's Lab plus the lift and the sweep scaled by k, so at k = 0 it
// is the ground exactly, which survives the round trip to the byte.
// Over the content the output is rebased so its neutral is white
// (multiply) or black (screen). The dither is one step of interleaved
// gradient noise on the encoded value, last, scaled away as k goes to
// zero: a value a hair under a step, pushed down half a step, rounds
// to the step below, and the last frame must be the ground to the byte.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform vec2 uResolution;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uGround;
uniform vec4 uField,uTone,uEnv,uMore;
uniform int uTerrain;
flat in vec3 vG;flat in vec4 vA;
out vec4 o;
float H(ivec2 c){uint h=uint(c.x)*0x9E3779B1u+uint(c.y)*0x85EBCA6Bu+uint(uTerrain)*0xC2B2AE35u;
h^=h>>15;h*=0x2C1B3C6Du;h^=h>>12;h*=0x297A2D39u;h^=h>>15;return float(h>>8)/16777216.;}
float N(vec2 p){vec2 i=floor(p),f=p-i,u=f*f*f*(f*(f*6.-15.)+10.);ivec2 c=ivec2(i);
return mix(mix(H(c),H(c+ivec2(1,0)),u.x),mix(H(c+ivec2(0,1)),H(c+ivec2(1,1)),u.x),u.y)*2.-1.;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<3;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=.5;}return s/n;}
vec3 P(vec3 c){vec3 l=mat3(1.,1.,1.,.3963378,-.1055613,-.0894842,.2158038,-.0638542,-1.2914855)*c;l=l*l*l;
return mat3(4.0767416,-1.2684380,-.0041961,-3.3077115,2.6097574,-.7034186,.2309699,-.3413194,1.7076147)*l;}
vec3 E(vec3 c){c=clamp(c,0.,1.);return mix(c*12.92,1.055*pow(c,vec3(1./2.4))-.055,step(.0031308,c));}
vec3 Q(vec3 l,float L){float g=L*L*L;vec3 d=l-g,k=mix((1.-g)/max(d,vec3(1e-6)),-g/min(d,vec3(-1e-6)),step(d,vec3(0.)));
return g+d*min(1.,min(k.x,min(k.y,k.z)));}
void main(){
vec2 r=uResolution,fc=gl_FragCoord.xy,q=fc/(uPixelRatio*uField.x)+uTime*uField.w;
if(uField.z>0.)q+=uField.z*vec2(F(q+vec2(3.1,7.7)),F(q+vec2(-5.3,1.9)));
float t=uProgress,e=t*t*(3.-2.*t),f=F(q),s=F(q+17.3),k,sp=1.;
float d=dot(fc/r-.5,vec2(cos(uEnv.y),sin(uEnv.y)))+.5;int m=int(uEnv.x+.5);
if(m==0)k=1.-e;
else if(m==1){float w=uEnv.z,g=mix(-3.*w,1.+3.*w,e)+w*f*.8;k=smoothstep(g-w,g+w,d);}
else if(m==2){float w=uEnv.w,g=mix(-3.*w,1.+3.*w,e)+w*f*.8,z=(d-g)/w;k=exp(-4.*z*z);}
else{sp=1.-smoothstep(0.,.5,t);k=1.-smoothstep(.5,1.,t);}
float lf=min(uTone.y,vA.y)*(vA.z>.5?1.:-1.)*k*(.6+.4*s),C=uTone.x*k*(.7+.3*f),h=vA.x+uTone.z*sp*f*6.2831853;
vec3 lab=vG+vec3(lf,C*cos(h),C*sin(h)),c=E(Q(P(lab),lab.x));
if(uMore.y>1.5)c=clamp((c-uGround)/max(1.-uGround,vec3(1e-4)),0.,1.);
else if(uMore.y>.5)c=clamp(c/max(uGround,vec3(1e-4)),0.,1.);
if(uTone.w>.5)c+=(fract(52.9829189*fract(dot(fc,vec2(.06711056,.00583715))))-.5)/255.*min(1.,k*4.);
o=vec4(c,1.);
}`;

/**
 * CSS tokens the shader gets as vec3 uniforms, with what stands in for
 * a missing one. The muted fallback is the grey that just passes 4.5
 * to 1 on white, so a page that has not named its weakest text gets
 * almost no lift rather than an unreadable one.
 */
export const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uGround', '--ground', '#fff'],
  ['uAccent', '--accent', '#00f'],
  ['uMuted', '--muted', '#767676'],
];

type Quad = [number, number, number, number];
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));

/** The options as the shader reads them, made safe. The panel can send any number. */
export function dials(o: Options): { field: Quad; tone: Quad; env: Quad; more: Quad; terrain: number } {
  return {
    field: [clamp(o.scale, 60, 2000), clamp(Math.round(o.octaves), 1, 3), clamp(o.warp, 0, 2), clamp(o.drift, 0, 0.5)],
    tone: [clamp(o.chroma, 0, 0.2), clamp(o.lift, 0, 0.2), clamp(o.spread, 0, 0.5), o.dither ? 1 : 0],
    env: [clamp(Math.round(o.envelope), 0, 3), ((Number.isFinite(o.direction) ? o.direction : 90) * Math.PI) / 180, clamp(o.softness, 0.01, 0.5), clamp(o.band, 0.05, 0.8)],
    more: [o.guard ? 1 : 0, clamp(Math.round(o.layer), 0, 2), 0, 0],
    terrain: Math.abs(Math.round(o.seed)) % 65536,
  };
}

const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  const d = dials(o);
  gl.uniform4f(at('uField'), ...d.field);
  gl.uniform4f(at('uTone'), ...d.tone);
  gl.uniform4f(at('uEnv'), ...d.env);
  gl.uniform4f(at('uMore'), ...d.more);
  gl.uniform1i(at('uTerrain'), d.terrain);
};

/** Its end is the page in its own tokens; a frozen tint would be a background with a contrast obligation that never ends. */
export const keepsStill = (_options: Options): boolean => false;
/** Drain, front and converge start from a full tint, which must not appear in a single frame. A band starts on plain ground. */
export const fadeInMs = (options: Options): number => (Math.round(options.envelope) === 2 ? 0 : 100);
/** Behind the content unless laid over it with a blend mode. */
export const layerUnder = (options: Options): boolean => Math.round(options.layer) === 0;
/** Over the content it multiplies on a light ground or screens on a dark one; the shader rebases its neutral to match. */
export const canvasStyle = (options: Options): string => {
  const layer = Math.round(options.layer);
  return layer === 1 ? 'mix-blend-mode:multiply' : layer === 2 ? 'mix-blend-mode:screen' : '';
};
const geometry = (gl: WebGL2RenderingContext, _options: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];
/** No depth buffer: one pass, nothing in front of anything. */
const DEPTH = false;
/** Nothing to set up before the first frame. */
const prepare: Prepare = () => {};
/** Starts after the first paint, wherever the element is. */
const startsOnView = (_options: Options): boolean => false;

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Front)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Resolve: {},
  Front: { envelope: 1 },
  Ribbon: { envelope: 2, lift: 0.03, band: 0.22 },
  Converge: { envelope: 3, spread: 0.4 },
  Unguarded: { guard: false, lift: 0.08, chroma: 0.08 },
};
// ---- HARNESS one-shot: begin ------------------------------------------------
//
// Identical in every one-shot shader recipe. `npm run check` fails if a
// copy differs, so change it in one recipe and copy it to the others.
//
// Above this line a recipe declares, by these names: Options (with
// duration, maxPixelRatio, maxPixels and arrivalOnly), DEFAULTS, Handle,
// VERTEX, FRAGMENT, TOKENS, uploadDials, keepsStill, fadeInMs,
// layerUnder, canvasStyle, geometry (the draw mode and vertex count),
// DEPTH (whether the context has a depth buffer and tests with it),
// prepare (the recipe's own setup after the program is in use, which
// may return a promise the start waits on) and startsOnView (whether
// nothing is made until the element enters the viewport).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It never
// asks the GPU a blocking question where a non-blocking one exists.
// When the effect ends, or on destroy, nothing of it remains.

/** The recipe's setup, given the context, the uniform locations, the host and the options; a promise delays the start. */
type Prepare = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, element: HTMLElement, options: Options) => void | Promise<void>;

const NOOP: Handle = { update() {}, destroy() {} };

/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();

/** The buffer size for a box measured in device pixels, under both ceilings. */
export function bufferSize(
  deviceWidth: number,
  deviceHeight: number,
  pixelRatio: number,
  maxPixelRatio: number,
  maxPixels: number,
): [width: number, height: number] {
  const area = deviceWidth * deviceHeight;
  if (!(area > 0)) return [1, 1];
  const scale = Math.min(1, maxPixelRatio / Math.max(pixelRatio, 0.01), Math.sqrt(maxPixels / area));
  return [Math.max(1, Math.round(deviceWidth * scale)), Math.max(1, Math.round(deviceHeight * scale))];
}

/** True when this page load came from outside the site, and is not a reload or a history move. */
export function isArrival(type: string | undefined, referrer: string, origin: string): boolean {
  if (type && type !== 'navigate') return false;
  if (!referrer) return true;
  try {
    return new URL(referrer).origin !== origin;
  } catch {
    return true;
  }
}

/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): [number, number, number] | null {
  const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
  if (!match) return null;
  const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
  return [channel(match[1]), channel(match[2]), channel(match[3])];
}

/** A CSS time, in ms. `null` for anything that is not one. */
export function parseTime(value: string): number | null {
  const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
  if (!match) return null;
  return match[2] === 's' ? Number(match[1]) * 1000 : Number(match[1]);
}

/**
 * Options over a base, keeping only what is the right type. A control
 * panel, a message from another frame and a careless caller can all
 * send anything, and none of them may reach the GPU unchecked.
 */
export function mergeOptions(base: Options, next: Partial<Options>): Options {
  const merged: Record<string, unknown> = { ...base };
  for (const [key, standard] of Object.entries(DEFAULTS)) {
    const value: unknown = (next as Record<string, unknown>)[key];
    if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
  }
  return merged as unknown as Options;
}

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  try {
    return start(element, options);
  } catch {
    return NOOP;
  }
}

function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
  if (!element || typeof WebGL2RenderingContext === 'undefined' || typeof ResizeObserver === 'undefined') return NOOP;
  // The one place motion is decided, so a host can override matchMedia.
  const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
  if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;

  mounted.get(element)?.destroy();

  const fromToken = parseTime(getComputedStyle(element).getPropertyValue('--load-duration'));
  let options = mergeOptions(fromToken === null ? DEFAULTS : { ...DEFAULTS, duration: fromToken }, explicit);
  if (options.arrivalOnly) {
    const entry = performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined;
    if (!isArrival(entry?.type, document.referrer, location.origin)) return NOOP;
  }

  const page = element === document.body || element === document.documentElement;
  const under = layerUnder(options);
  const canvas = document.createElement('canvas');
  canvas.setAttribute('aria-hidden', 'true');
  canvas.style.cssText = `position:${page ? 'fixed' : 'absolute'};inset:0;width:100%;height:100%;pointer-events:none;z-index:${under ? -1 : 2147483647};${canvasStyle(options)}`;

  let gl: WebGL2RenderingContext | null = null;
  let program: WebGLProgram | null = null;
  let observer: ResizeObserver | null = null;
  let clock: Animation | null = null;
  let still: HTMLCanvasElement | null = null;
  let seen: IntersectionObserver | null = null;
  let hostStyle: string | null | undefined;
  let frame = 0;
  let timer = 0;
  let slow = 0;
  let last = 0;
  let seconds = 0;
  let done = false;
  const locations = new Map<string, WebGLUniformLocation | null>();
  const at = (name: string): WebGLUniformLocation | null => {
    if (!locations.has(name)) locations.set(name, gl && program ? gl.getUniformLocation(program, name) : null);
    return locations.get(name) ?? null;
  };

  /** Ends the effect. With `keep`, the last frame stays as a plain 2D canvas. */
  const finish = (keep: boolean): void => {
    if (done) return;
    done = true;
    cancelAnimationFrame(frame);
    clearTimeout(timer);
    observer?.disconnect();
    seen?.disconnect();
    clock?.cancel();
    document.removeEventListener('visibilitychange', wake);
    removeEventListener('pagehide', stop);
    removeEventListener('pageswap', stop);
    canvas.removeEventListener('webglcontextlost', stop);
    if (keep && gl && canvas.isConnected) {
      // Copied in the same task as the last draw: after the next
      // composite the drawing buffer is gone.
      still = document.createElement('canvas');
      still.width = canvas.width;
      still.height = canvas.height;
      still.setAttribute('aria-hidden', 'true');
      still.style.cssText = canvas.style.cssText;
      still.dataset.state = 'rest';
      still.getContext('2d')?.drawImage(canvas, 0, 0);
      canvas.replaceWith(still);
    } else {
      // Removed first, then released: a lost canvas left in the page
      // shows a broken-image mark.
      canvas.remove();
    }
    if (gl) {
      gl.deleteProgram(program);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    }
    canvas.width = canvas.height = 0;
    gl = program = null;
    if (!still) restoreHost();
  };
  const stop = (): void => finish(false);

  const restoreHost = (): void => {
    if (hostStyle === undefined) return;
    // Read it before removing it. In Chrome, a style set through
    // `.style` and removed before anyone has read the attribute leaves
    // `style=""` behind, and "as it was" would be nearly true.
    element.getAttribute('style');
    if (hostStyle === null) element.removeAttribute('style');
    else element.setAttribute('style', hostStyle);
    hostStyle = undefined;
  };

  const tokens = (): void => {
    if (!gl) return;
    const probe = canvas.style;
    for (const [uniform, property, fallback] of TOKENS) {
      // Any colour syntax in, one serialisation out: mixing a colour
      // with itself in sRGB makes the browser do the conversion.
      probe.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
      const rgb = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
      gl.uniform3f(at(uniform), rgb[0], rgb[1], rgb[2]);
    }
    probe.color = '';
  };

  const draw = (now: number): void => {
    frame = 0;
    if (done || !gl || !clock) return;
    const progress = clock.effect?.getComputedTiming().progress ?? 1;
    const gap = last ? now - last : 0;
    last = now;
    seconds += Math.min(gap, 100) / 1000;
    // Four frames in a row slower than 15 a second: this is not a GPU worth keeping.
    slow = gap > 66 ? slow + 1 : 0;
    gl.uniform1f(at('uProgress'), progress);
    gl.uniform1f(at('uTime'), seconds);
    uploadDials(gl, at, options);
    // Cleared first: a fragment that discards, above a horizon, must not
    // show the last frame through the hole.
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    const [mode, count] = geometry(gl, options);
    gl.drawArrays(mode, 0, count);
    if (progress >= 1) finish(keepsStill(options));
    else if (slow > 3) finish(false);
    else frame = requestAnimationFrame(draw);
  };

  const resize = (entries: ResizeObserverEntry[]): void => {
    const entry = entries[0];
    if (done || !gl || !entry) return;
    const ratio = devicePixelRatio || 1;
    const exact = entry.devicePixelContentBoxSize?.[0];
    const box = entry.contentBoxSize?.[0];
    const cssWidth = box ? box.inlineSize : entry.contentRect.width;
    const cssHeight = box ? box.blockSize : entry.contentRect.height;
    // The device-pixel box is for snapping, not for scale: it is trusted
    // only where it agrees with devicePixelRatio to within a pixel.
    // Under DevTools device emulation it reports CSS pixels.
    const snapped = exact && Math.abs(exact.inlineSize - cssWidth * ratio) < 2 && Math.abs(exact.blockSize - cssHeight * ratio) < 2;
    const [width, height] = bufferSize(
      snapped ? exact.inlineSize : cssWidth * ratio,
      snapped ? exact.blockSize : cssHeight * ratio,
      ratio,
      options.maxPixelRatio,
      options.maxPixels,
    );
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, width, height);
    }
    gl.uniform2f(at('uResolution'), width, height);
    gl.uniform1f(at('uPixelRatio'), width / Math.max(1, cssWidth));
    if (!clock) {
      // The clock is a Web Animation with nothing in it: the browser
      // keeps the time and applies the easing token.
      const easing = getComputedStyle(element).getPropertyValue('--load-ease').trim() || 'linear';
      const timing = { duration: Math.max(options.duration, 1), fill: 'forwards' as const };
      try {
        clock = canvas.animate(null, { ...timing, easing });
      } catch {
        clock = canvas.animate(null, timing);
      }
      const fade = fadeInMs(options);
      if (fade > 0) canvas.animate({ opacity: [0, 1] }, { duration: fade });
    }
    // Sizing clears the buffer, so draw before this frame is shown.
    cancelAnimationFrame(frame);
    draw(performance.now());
  };

  const attach = (): void => {
    if (done || !gl) return;
    if (under || !page) {
      // The host becomes the canvas's box, and behind the content a
      // stacking context too, so the canvas sits above its background
      // and below everything in it.
      hostStyle = element.getAttribute('style');
      if (getComputedStyle(element).position === 'static') element.style.position = 'relative';
    }
    if (under) {
      element.style.isolation = 'isolate';
      element.prepend(canvas);
    } else {
      element.append(canvas);
    }
    canvas.dataset.state = 'running';
    // In the document first: an element outside it has no computed style.
    tokens();
    observer = new ResizeObserver(resize);
    try {
      observer.observe(canvas, { box: 'device-pixel-content-box' });
    } catch {
      observer.observe(canvas);
    }
  };

  const ready = (): void => {
    if (done || !gl || !program) return;
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return stop();
    gl.useProgram(program);
    if (DEPTH) gl.enable(gl.DEPTH_TEST);
    gl.uniform1f(at('uSeed'), Math.random());
    // The recipe's own setup, which may wait: for a font, for a picture.
    // A throw, sync or async, ends the effect with nothing shown.
    const context = gl;
    Promise.resolve()
      .then(() => prepare(context, at, element, options))
      .then(attach, stop);
  };

  const build = (): void => {
    if (done || gl) return;
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', wake);
    gl = canvas.getContext('webgl2', {
      alpha: true,
      antialias: false,
      depth: DEPTH,
      stencil: false,
      powerPreference: 'low-power',
      // A software renderer would burn a weak machine's cores for decoration.
      failIfMajorPerformanceCaveat: true,
    });
    if (!gl) return stop();
    canvas.addEventListener('webglcontextlost', stop);
    program = gl.createProgram();
    for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
      const shader = gl.createShader(type);
      if (!shader) return stop();
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      gl.attachShader(program, shader);
      gl.deleteShader(shader);
    }
    gl.linkProgram(program);
    const parallel = gl.getExtension('KHR_parallel_shader_compile');
    let waited = 0;
    const poll = (): void => {
      frame = 0;
      if (done || !gl || !program) return;
      waited += 1;
      // With the extension, ask without blocking. Without it, give the
      // driver two frames, then ask and take the wait. Asking early
      // moved first paint by more than a second in testing.
      if (parallel ? gl.getProgramParameter(program, parallel.COMPLETION_STATUS_KHR) : waited > 2) ready();
      else frame = requestAnimationFrame(poll);
    };
    frame = requestAnimationFrame(poll);
  };
  function wake(): void {
    build();
  }

  const handle: Handle = {
    update(next) {
      options = mergeOptions(options, next);
    },
    destroy() {
      stop();
      still?.remove();
      still = null;
      restoreHost();
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);

  addEventListener('pagehide', stop);
  addEventListener('pageswap', stop);
  document.addEventListener('visibilitychange', wake);
  // After a paint, never before one: the page is on screen first, and
  // creating a context is a blocking call of tens of milliseconds.
  const begin = (): void => {
    frame = requestAnimationFrame(() => {
      frame = 0;
      timer = window.setTimeout(build, 0);
    });
  };
  if (startsOnView(options) && typeof IntersectionObserver === 'function') {
    // Nothing is made until the element is in view: no context held
    // below the fold, and the recipe prepares against the layout and
    // fonts of the moment it starts.
    seen = new IntersectionObserver((entries) => {
      if (done || !entries.some((entry) => entry.isIntersecting)) return;
      seen?.disconnect();
      seen = null;
      begin();
    });
    seen.observe(element);
  } else {
    begin();
  }
  return handle;
}

// ---- HARNESS one-shot: end --------------------------------------------------