atelier

Catalog / Recipe

Contour field

A contour map of a noise field, in the page's own ink, that reveals itself over the page and leaves.

Family
topology
Status
lab
Used for
Page loadHeroSection introBackground
Size
4,401 of 4,500 bytes, gzipped
Frame
Skin
Version
reveal
tint
mount(element)

A terrain that is not there, surveyed in the page’s own line. The lines are drawn by the fragment shader from a height field, so there is no geometry, no texture and nothing to load.

Use

import { mount } from './contour-field';

mount(document.body);

The four reveals

reveal Name What happens Ends on
0 Tide A level rises through the terrain. Lines exist only below it, and the level itself is one heavier line in the accent. Islands appear, grow and join nothing, or the full map
1 Survey Index contours fade in, then the rest. Nothing moves nothing, or the full map
2 Drain The relief falls away and the lines leave through the summits and pits nothing
3 Rule The terrain relaxes into evenly spaced horizontal rules nothing, or the rules

A line cannot be “drawn on” along its length here: an implicit contour has no start and no end. Tide is the honest version of that idea.

Options

Option Default Meaning
reveal 0 Which reveal, as above
featureSize 180 Size of one hill, in CSS px
octaves 3 Layers of detail, 1 to 5
gain 0.5 Strength of each finer layer
warp 0 How far the terrain is folded over itself
levels 11 How many contour levels cross the field
lineWidth 1.25 Width of an ordinary contour, in CSS px
indexWidth 2 Width of an index contour, in CSS px
index true Every fifth contour is heavier
ink 0.6 Opacity of the lines while it plays
stillInk 0.2 Opacity of the lines in the still
drift 0.25 How fast the terrain turns over while it plays. 0 freezes it
accent true The tide’s edge is in the accent colour
keepStill false Leave the last frame behind
under false Draw behind the element’s content
seed 7 Which terrain
tint 0 What colours the lines: 0 the ink, 1 the accent, 2 ink in the low ground rising to accent on the heights
duration, arrivalOnly, maxPixelRatio, maxPixels 900, true, 2, 2,500,000 As every one-shot shader recipe

With tint at 1 the lines are already the accent, so the tide’s edge is drawn in ink instead: an accent edge on accent lines is no edge.

Versions

PRESETS holds sets of options that belong together. One goes where the options go: mount(document.body, PRESETS.Blueprint).

Version What it is
Survey The defaults: ink lines, a tide with an accent edge
Blueprint Lines in the accent colour, finer and closer together
Relief Ink in the valleys rising to the accent on the heights, over folded ground
Quiet Fewer, fainter and larger, for where it has to stay in the background
Strata Relaxes into horizontal rules and leaves them behind as a still

None of them has been judged in motion on a real page. They are starting points, and the colour ones in particular depend on the skin’s accent.

Where it must not go

  • Behind body text, at any strength. Lines through 16 px type are busy even at 18%.
  • At full strength in the same region as any text.
  • Beside real data. Contour lines next to a chart or a map read as part of it.

Learned while building it

  • Size the terrain in CSS pixels. Scaled to the element, settings that looked right in a 300 px tile were far too busy in a band behind a headline.
  • Differentiate the continuous height, not the wrapped one, and use the length of the derivative. fwidth makes diagonal lines fatter.
  • Drop intermediate lines on steep ground. Otherwise a steep slope is a black smear.
  • Output premultiplied ink on a transparent canvas. There is then no ground colour to match, and the same shader is right on both themes.
  • A noise field does not fill its own range. Layered gradient noise stayed within about -0.4 to 0.4, so levels spread over -1 to 1 drew four lines where eleven were asked for, and the tide crossed the whole map in a fifth of its time. The field is scaled before the levels are laid over it. This only showed in a still.
  • Stills are taken with linear easing. Under the skin’s own easing a frame at 35% of the time is at 50% of the reveal, and the two were being confused.
contour-field.ts607 lines · 4,401 bytes gzipped · no imports
/**
 * contour-field
 *
 * A contour map of a seeded noise field, drawn in the page's own ink on
 * a transparent canvas, that reveals itself over the page and leaves.
 * The ground is the page's, so it reads the same on a light theme and a
 * dark one.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './contour-field';
 *     mount(document.body);
 *
 * Four reveals: a tide that rises through the terrain (the default), a
 * survey that fills the map in, a drain that empties it, and a rule
 * that relaxes it into the page's own lines.
 */

export interface Options {
  /** How long the reveal takes, in ms. Falls back to --load-duration. */
  duration: number;
  /** Ceiling on canvas pixels per CSS pixel. Lines need pixels more than a colour field does. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Play only when the reader arrived from outside the site. */
  arrivalOnly: boolean;
  /** Which reveal: 0 tide, 1 survey, 2 drain, 3 rule. */
  reveal: number;
  /** Size of one hill, in CSS px. Never a share of the element: a band and a tile need the same terrain. */
  featureSize: number;
  /** Layers of detail, 1 to 5. Cost is linear in this. */
  octaves: number;
  /** How strong each finer layer is, against the one before. */
  gain: number;
  /** How far the terrain is folded over itself. 0 is plain hills. */
  warp: number;
  /** How many contour levels cross the field. */
  levels: number;
  /** Width of an ordinary contour, in CSS px. */
  lineWidth: number;
  /** Width of an index contour, in CSS px. */
  indexWidth: number;
  /** Every fifth contour is heavier, as on a printed map. */
  index: boolean;
  /** Opacity of the lines while the reveal plays. */
  ink: number;
  /** Opacity of the lines in the still that is left behind. Over 0.3 costs reading. */
  stillInk: number;
  /** How fast the terrain turns over while it plays, in radians a second. 0 freezes it. */
  drift: number;
  /** The tide's leading edge is drawn in the accent colour. */
  accent: boolean;
  /** Leave the last frame behind as a still. Otherwise it ends on nothing. */
  keepStill: boolean;
  /** Draw behind the element's content instead of over it. */
  under: boolean;
  /** Which terrain. The same number is the same map. */
  seed: number;
  /** What colours the lines: 0 the ink, 1 the accent, 2 ink in the low ground rising to accent on the heights. */
  tint: number;
}

export const DEFAULTS: Options = {
  duration: 900,
  maxPixelRatio: 2,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  reveal: 0,
  featureSize: 180,
  octaves: 3,
  gain: 0.5,
  warp: 0,
  levels: 11,
  lineWidth: 1.25,
  indexWidth: 2,
  index: true,
  ink: 0.6,
  stillInk: 0.2,
  drift: 0.25,
  accent: true,
  keepStill: false,
  under: false,
  seed: 7,
  tint: 0,
};

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

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

// H: an integer hash of a lattice corner, with the terrain number mixed
//    in. Integers, not a sine: the sine hash differs between GPUs.
// G: the gradient at a corner, turning with time. That turn is the drift.
// N: gradient noise, quintic fade, scaled to about -1..1.
// F: octaves of N, normalised by the sum of their weights, so the range
//    does not change when the octave count does. In practice it stays
//    within about -0.4..0.4, not -1..1: main scales it by 1.25 so that
//    the levels are spread over the height the terrain really has.
//    Without that, "11 levels" drew four, and the tide crossed the whole
//    map in a fifth of its time.
// W: how fast a value changes per pixel. The length of the derivative,
//    not fwidth: fwidth makes diagonal lines thicker than upright ones.
// S: one set of level lines at constant width. `d` is the distance in
//    pixels to the nearest level. Below one pixel a line fades instead
//    of thinning, and where levels come closer than a few pixels the
//    set drops out, which is what keeps steep ground from going black.
//
// main: v is the height in level units. Each reveal is a different
// function of v and progress t:
//   0 tide    lines exist only below a level L that rises to the top by
//             t = 0.7; L itself is one heavier line in the accent.
//   1 survey  index contours fade in, then the rest.
//   2 drain   the relief falls to nothing with levels at half steps, so
//             every line leaves through a summit or a pit.
//   3 rule    the height blends into a plain ramp, so the contours
//             become evenly spaced horizontal rules.
// The last 28% fades the ink to nothing, or to the still's strength.
// T picks the line colour: the ink, the accent, or one into the other
// by height. With accent lines the tide's edge is drawn in ink instead,
// or it would vanish into them.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uInk,uAccent;
uniform vec4 uField,uLines,uLook,uMore;
uniform int uTerrain;
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.;}
vec2 G(ivec2 c){float a=H(c)*6.2831853+uTime*uLook.z;return vec2(cos(a),sin(a));}
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(dot(G(c),f),dot(G(c+ivec2(1,0)),f-vec2(1,0)),u.x),
mix(dot(G(c+ivec2(0,1)),f-vec2(0,1)),dot(G(c+ivec2(1,1)),f-1.),u.x),u.y)*1.41421356;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<5;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=uField.z;}return s/n;}
float W(float v){return length(vec2(dFdx(v),dFdy(v)));}
float S(float v,float w){float g=W(v),d=abs(fract(v-.5)-.5)/max(g,1e-6),k=max(w,1.);
return(1.-smoothstep(k*.5-.5,k*.5+.5,d))*min(w,1.)*(1.-smoothstep(.18,.36,g));}
void main(){
vec2 p=gl_FragCoord.xy/(uPixelRatio*uField.x);
if(uField.w>0.)p+=uField.w*vec2(F(p+vec2(3.1,7.7)),F(p+vec2(-5.3,1.9)));
float t=uProgress,n=uLines.x,f=F(p)*1.25,v=(f+.5)*n;
float w=uLines.y*uPixelRatio,x=uLines.w*uPixelRatio,e=uLines.z;
int m=int(uLook.w);float a=0.,c=0.;
if(m==0){float L=mix(-.5,n+.5,min(t/.7,1.));
a=max(S(v,w),e>.5?S(v/e,x):0.)*(1.-smoothstep(L-.5,L,v));
c=(1.-smoothstep(x*.5-.5,x*.5+.5,abs(v-L)/max(W(v),1e-6)))*smoothstep(0.,.06,t)*(1.-smoothstep(.62,.7,t));}
else if(m==1){a=max((e>.5?S(v/e,x):0.)*smoothstep(0.,.25,t),S(v,w)*smoothstep(.3,.65,t));}
else if(m==2){a=S(f*n*(1.-t)*(1.-t)+.5,w);}
else{float s=t*t*(3.-2.*t);v=mix(v,gl_FragCoord.y/(uPixelRatio*28.),s);
a=max(S(v,w),e>.5&&s<.999?S(v/e,x):0.);}
float k=mix(uLook.x,uMore.y>.5?uLook.y:0.,smoothstep(.72,1.,t));
float A=a*k,C=c*uMore.x*step(.001,k),al=max(A,C),T=uMore.z;
vec3 b=mix(uInk,uAccent,T<.5?0.:T<1.5?1.:clamp(f+.5,0.,1.));
o=vec4(mix(b,T>.5&&T<1.5?uInk:uAccent,C)*al,al);
}`;

/** CSS tokens the shader gets as vec3 uniforms, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uInk', '--ink', '#000'],
  ['uAccent', '--accent', '#00f'],
];

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. A threshold the
 * shader cannot survive is not an error to report: the panel can send
 * any number, and the answer is the nearest one that works.
 */
export function dials(o: Options): { field: Quad; lines: Quad; look: Quad; more: Quad; terrain: number } {
  return {
    field: [clamp(o.featureSize, 20, 2000), clamp(Math.round(o.octaves), 1, 5), clamp(o.gain, 0.1, 0.9), clamp(o.warp, 0, 1.5)],
    lines: [clamp(o.levels, 2, 40), clamp(o.lineWidth, 0.25, 6), o.index ? 5 : 0, clamp(o.indexWidth, 0.25, 8)],
    look: [clamp(o.ink, 0, 1), clamp(o.stillInk, 0, 1), clamp(o.drift, 0, 2), clamp(Math.round(o.reveal), 0, 3)],
    more: [o.accent ? 1 : 0, o.keepStill ? 1 : 0, clamp(Math.round(o.tint), 0, 2), 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('uLines'), ...d.lines);
  gl.uniform4f(at('uLook'), ...d.look);
  gl.uniform4f(at('uMore'), ...d.more);
  gl.uniform1i(at('uTerrain'), d.terrain);
};

/** A still is worth keeping only for a reveal that ends on a drawing: drain ends empty. */
const keepsStill = (options: Options): boolean => options.keepStill && Math.round(options.reveal) !== 2;
/** Drain and rule begin from the whole map, which must not appear in a single frame. */
const fadeInMs = (options: Options): number => (Math.round(options.reveal) >= 2 ? 100 : 0);
const layerUnder = (options: Options): boolean => options.under;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** One triangle that covers the canvas; the fragment shader does the rest. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
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.Blueprint)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Survey: {},
  Blueprint: { tint: 1, ink: 0.7, levels: 14, lineWidth: 1 },
  Relief: { tint: 2, warp: 0.35, octaves: 4, ink: 0.75 },
  Quiet: { ink: 0.35, levels: 7, featureSize: 260, index: false, accent: false },
  Strata: { reveal: 3, keepStill: true, stillInk: 0.25, levels: 9 },
};
// ---- 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 --------------------------------------------------