atelier

Catalog / Recipe

Ink resolve

A heading's word, or a picture, starts as stirred ink and resolves to itself over its budget, then crossfades onto the real element; reversed, it leaves as wisps.

Family
feedback loop
Status
passed
Used for
HeroSection introPage load
Size
7,098 of 7,200 bytes, gzipped
Frame
Skin
Version
tint
direction
core
mount(element)

The feedback loop of feedback-trails given a seed to relax toward. Dye is advected by the curl of a gradient-noise field, which is divergence-free so the ink swirls and stretches without bunching up, diffused a little so stretched filaments thin, and each frame a share of it is pulled back toward the seed: the word as the page draws it, or the picture. The pull rises as the cube of progress, so the ink is wild early and clean late, and at the end the canvas crossfades onto the real element. The dye is a concentration and is shown as ink: absorbance over the page’s ground, so pools go deeper than the token and thin washes are pale tints of it. Held at a constant pull on a moving field, the word never settles: the Live version.

Use

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

mount(document.querySelector('.hero'), { budget: 4000 });

The mark is the first [data-mask] inside the element, else its first h1 or h2, else its first img. A text mark is one line at display size; it stays visible in the page’s colour under the canvas the whole time, and the ink is the accent token over it, so the handover is a crossfade and not a jump. A picture is covered from the first frame and is the picture again when the canvas comes off.

The seed

A word is rasterised as the page renders it: whitespace collapsed, the transform applied in the page’s language (Turkish İ and ı survive it), one line measured through a Range, the half-leading baseline from measureText, letter spacing and stretch, after the fonts settle or 1.5 s. It is drawn white on transparent at the simulation’s size and uploaded once. A picture is drawn once it has loaded, cropped as object-fit: cover shows it, where the img sits in the host and transparent elsewhere, so a caption beside it stays visible. Nothing is drawn before the seed is, and the resolve fits into what the budget has left after the wait.

The loop

  1. Init, once: soft drops of ink over the field at grain size for an arrival, two octaves thresholded softly with a finer noise for the density inside them, so it starts disordered and the drops have an inside; the seed itself for a departure.
  2. Each frame, steps times: the dye is read from where the curl of the noise says it came from (two octaves, the second finer and weaker), diffused through its four neighbours by soften, thinned by fade, then mixed toward the seed by the frame’s pull, or held at live.
  3. Show: a word’s dye as absorbance over the page’s ground, written premultiplied over transparent, darkened at the rim where it exceeds the ring around it, with the paper’s tooth in the washes; over a dark ground an opacity ramp in the token instead, since absorbance there is invisible. A picture’s dye over the ground. The harness’s ease over the last half second fades the word’s ink out, and a picture’s dye toward the picture itself.

Options

Option Default What it does
stir 0.004 How far the ink moves a step, as a share of the width
scale 3 Cells of the field across the box; low is one swirl
drift 0.004 How fast the eddies move and reform; 0.05 for a live field
pull 0.25 The pull toward the seed at the end; high is a snap
grain 0.08 The size of the blots an arrival starts from
steps 1 Steps a frame at 60 Hz; a faster screen spreads them, a slower one takes up to four at once
octaves 2 Octaves of stir; 1 is calmer and half the step cost
soften 0.015 Diffusion a step, up to 0.1; stretched filaments thin
fade 0.003 Loss a step, up to 0.01; the haze clears
depth 1.8 How dark the densest ink is, 1 to 3; pools deeper than the token, washes paler
rim 0.3 Ink gathered at the rims, up to 0.5; above that it outlines letters and pebbles pools
gran 0.1 The paper’s tooth in the washes, up to 0.3; 0.3 reads as spray
core 0 1 is blue-black pools with the accent only in the washes; no effect over a dark ground, whose ramp is in the token alone
live 0 The pull to hold, up to 0.1; 0 is off. Held, the word never settles
tint 0 0 accent, 1 ink
direction 0 0 arrive, 1 depart
budget 4000 ms of running time; the resolve fills what is left of it after the wait for the seed. 0 resolves over four seconds and runs until destroy
keepStill false Keep the resolved ink as a still when the budget ends
simSize, paused, maxPixelRatio, maxPixels 512, false, 1.5, 2,500,000 As every stateful recipe

Versions

Version What it is
Resolve The defaults: blots stirred into the word
Slow A late, gentle resolve over five seconds
Turbulent Fine, fast wisps
Depart The word leaves as ink
Picture Settings for a picture mark; on the band it is the word
Live The word held as living ink, forever at budget 0; the page must offer a pause control
Blue-black Near-black pools, the accent in the washes

Where it must not go

Never on body text, and never on something the reader needs in the first half second: the word is unreadable in ink for most of the run. Not over data, a listing or a card grid. A hero, a section’s heading, a page’s arrival. Live only where a pause control exists and never beside a passage of reading: a word that never stops pulls the eye off the text next to it.

What it does when things are missing

Situation What the reader sees
No WebGL2, a software renderer The page as served
No float target 8-bit dye with a floor of half a level a step; the washes band a little
An accent as light as the ground, or lighter On a light ground nothing is drawn: there is no light for white ink to absorb. Use tint 1 for the ink token
No mark, an empty or wrapped mark The page as served; nothing is attached
A picture from another origin, a mark that wraps once the real font is in The page as served under a canvas that draws nothing, then rests
Fonts late Nothing is drawn; after 1.5 s the seed is drawn on the face in use and the resolve takes what is left
A picture not yet loaded Nothing is drawn until it is
Reduced motion, forced colours The page as served
Off screen, hidden, paused No frames; the run begins when the element is seen
The budget ends The page as served, under a faded-out canvas that is removed; or the resolved ink as a still with keepStill
Context lost The still if kept, else nothing
Destroy The page as served

Learned while building it

  • Steps must be counted in time, not per frame. The first build took two steps every frame, and headless Chrome at 240 fps gave it four times the research’s stirring: soft clouds instead of wisps. Steps now accrue at a rate of so many per frame of 60 Hz, spread over faster screens and bunched on slower ones, and the marbled filaments appeared at once.
  • A departure must relax toward nothing. Advection conserves the dye, so a departure that merely let go of the seed left all its ink on the field forever, scattered. With the pull aimed at empty, Depart measured no ink at 0.9 of its budget.
  • Registration carried over from point-gather unchanged: at 0.8 of the budget 91% of the lit ink sat within 3 px of the page’s own glyphs and 98% of the glyphs had ink on them, with the word visible under the canvas throughout.
  • The picture handover is exact only on the last frame, where the ease has run out; a frame taken during the crossfade still differs by a few percent. The check measures the kept still, which is that last frame, on the screen: 1.4% of pixels differ by more than 8 levels, the resampling of a cover crop drawn at the simulation’s size.
  • A mostly white picture is a poor witness for “covered”: its own colours over a light ground differ little from the ground. The check uses the dusk tile.
  • The first build uploaded a picture at mount, as it was: an img still loading went up as an empty texture and the tile was a black box until the crossfade, and a picture of another shape than its box was stretched. The seed now waits for load and is drawn cover- cropped, and the show pass multiplies by the seed’s alpha so the canvas is transparent off the picture: on the gallery tile the caption’s pixels did not change at all.
  • A word that fits on the face in use can wrap once the real font is in, 1.5 s after mount, and drawing it then threw out of the harness’s frame. Drawing the seed now sits in a try, and a seed that cannot be drawn rests the loop with nothing attached but a canvas that drew nothing.
  • A screenshot’s clip is in page coordinates. The first picture check clipped at viewport coordinates on a scrolled page and measured a region above the tile, which no effect touched: 0.000 changed, and 0.000 differing, both passing-looking numbers for the wrong reason.
  • The jaggedness the owner saw was the noise, not the pixels. Value noise with a cubic fade has a crease along every lattice line, and the dye sheared along the creases into straight edges and right angles. Gradient noise with a quintic fade removed every straight segment for 53 bytes. Only 1.9% of edge pixels were steep, and simulations of 384 and 1024 texels looked alike.
  • Flat colour times alpha is smoke, not ink. Absorbance over the ground (Beer-Lambert, premultiplied) gives dark pools and pale tints of the hue; measured at 0.8 of the run, the darkest tenth of lit pixels at luminance 45 and the lightest at 186 against the token’s 91. Over a dark ground there is no light to absorb and the ink vanished; an opacity ramp in the token shows it (coverage 0.93 on a near-black host).
  • Absorbance makes the thin washes visible, so the checks changed what they count. A wash of concentration 0.05 differs from the ground by about 57 levels, which the old 40-level edge read as haze; the registration and haze checks now count dense ink, 120 levels and more: at 0.8, 0.88 of it on the word and 3,418 pixels outside.
  • A fixed field under a constant pull settles to one image, which is why budget 0 still stopped. With the pull held at 0.04 and drift 0.05, 16.6% of the field changed between 20 s and 25 s and the word stayed covered (0.98); with drift 0 and the same pull, nothing changed. Legibility is the stir-to-pull ratio, 0.05 to 0.13.
  • A fade by multiplication stalls in 8 bits, so that path subtracts half a level a step; with no float target the word still resolved (coverage 0.98, 3,206 dense pixels outside).
  • A uniform may not be named floor: it shadows the GLSL built-in and the program fails to link, and the harness then mounts nothing, with nothing logged to the page. The first run of the rework drew no canvas for that reason.
  • Cost: 7,098 bytes gzipped after the review pass, 7,113 before it (the budget measures minified code; the comments are free). On an integrated GPU the step is about 0.22 ms and the show about 0.6 ms a frame at 1,440 wide, the show’s cost its per-pixel exp and log and the tooth’s noise. Steps are counted in time, so a 240 Hz screen takes the same steps a second as a 60 Hz one; what it pays four times as often is the show, which the harness draws every frame: about 144 ms of GPU a second against 36, and Live never rests.
  • The pull is per step, like the stir. The first build split a frame’s pull over its steps, which halved the pull per step on a 30 Hz screen and doubled the stir-to-pull ratio: Live measured dense coverage 0.68 at 30 Hz against 0.89 at the full rate, the word half gone. With the pull per step the two rates measure alike.
  • Absorbance at depth 1.8 makes thin dye three to four times as visible as the flat colour did, so the pale tint over the field at 0.8 of a run counts 9,951 pixels at the old 40-level edge where Lab 12 counted 2,602. The stills read it as paper wash; fade up to 0.01 and depth down toward 1 clear it, and the owner’s verdict on the recipe page decides the defaults.
ink-resolve.ts1073 lines · 7,098 bytes gzipped · no imports
/**
 * ink-resolve
 *
 * A heading's word, or a picture, starts as stirred ink and resolves to
 * itself: dye advected by curl noise, pulled toward the seed as progress
 * rises, then a crossfade onto the real element. Reversed, the word
 * leaves as wisps. The feedback loop of `feedback-trails` with a seed
 * to relax toward.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './ink-resolve';
 *     mount(document.querySelector('.hero'));
 *
 * The mark is the first [data-mask] inside the element, else its first
 * heading, else its first img. A text mark is one line at display size,
 * rasterised as the page renders it and registered to the live text,
 * which stays visible under the canvas throughout. It runs for its
 * budget, four seconds by default, when the element is on screen.
 */

export interface Options {
  /** Ceiling on canvas pixels per CSS pixel. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Running time in ms for the resolve; 0 runs the resolve over four seconds and the loop until destroy. */
  budget: number;
  /** Stops the frames. Nothing is lost; unpausing carries on. */
  paused: boolean;
  /** When the budget ends it, leave the last frame as a plain picture. */
  keepStill: boolean;
  /** Long side of the simulation in texels. The cost dial. */
  simSize: number;
  /** How far the ink is stirred a step, as a share of the width, 0.001 to 0.01. */
  stir: number;
  /** Cells of the stirring field across the box, 1.5 to 8. Low is one swirl, high is fine turbulence. */
  scale: number;
  /** How fast the eddies move and reform, 0 to 0.2. Zero is a fixed pattern; a resolve wants about 0.004, a live field 0.05. */
  drift: number;
  /** The pull toward the seed at the end, 0.1 to 1. Low is a slow, late resolve; high a snap. */
  pull: number;
  /** Size of the ink blots the arrival starts from, as a share of the height, 0.02 to 0.3. */
  grain: number;
  /** Steps a frame at 60 Hz, 1 to 4; a faster screen takes them over more frames, a slower one up to four at once. */
  steps: number;
  /** Octaves of stir, 1 or 2: the second at twice the frequency and half the strength puts curls inside the swirls, at twice the step cost. */
  octaves: number;
  /** Diffusion a step, 0 to 0.1: what makes a stretched filament thin. 0 is crisp; 0.1 is soft cloud. */
  soften: number;
  /** Share of the dye lost a step, 0 to 0.01: clears the haze. The word itself thins too unless the pull is high. */
  fade: number;
  /** How dark the densest ink is, 1 to 3: 1 is the token colour itself; higher, pools go deeper than the token and washes stay pale. */
  depth: number;
  /** Ink gathered at the rims as it dries, 0 to 0.5. Above that it outlines letters and pebbles pools. */
  rim: number;
  /** The paper's tooth in the thin washes, 0 to 0.3. 0.3 reads as spray. */
  gran: number;
  /** 0 the accent throughout; 1 blue-black pools with the accent only in the washes. */
  core: number;
  /** The pull to hold once the arrival's rising pull reaches it, 0 to 0.1; 0 is off. Held, the word re-forms every frame while wisps stream off it, and never settles: legible from about 0.03 to 0.08 at stir 0.004. With budget 0 it runs until destroy. */
  live: number;
  /** Which token colours the ink: 0 accent, 1 ink. */
  tint: number;
  /** 0 arrive, from stirred ink to the word; 1 depart, from the word to wisps. */
  direction: number;
}

export const DEFAULTS: Options = {
  maxPixelRatio: 1.5,
  maxPixels: 2_500_000,
  budget: 4000,
  paused: false,
  keepStill: false,
  simSize: 512,
  stir: 0.004,
  scale: 3,
  drift: 0.004,
  pull: 0.25,
  grain: 0.08,
  steps: 1,
  octaves: 2,
  soften: 0.015,
  fade: 0.003,
  depth: 1.8,
  rim: 0.3,
  gran: 0.1,
  core: 0,
  live: 0,
  tint: 0,
  direction: 0,
};

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

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

// Gradient noise with a quintic fade, and its curl by finite difference:
// the rotated gradient, divergence-free, so ink swirls and stretches
// without bunching up. Value noise with a cubic fade was the first build,
// and its slope bends at every lattice line: the dye sheared along the
// creases into straight edges and right angles, which the owner read as
// jagged. The quintic fade's first and second derivatives are zero at
// the cell edges, so the curl is smooth across them. The field's aspect
// goes into the noise so eddies are round.
const NOISE = `float r2(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5);}
vec2 g2(vec2 p){float a=6.2832*r2(p);return vec2(cos(a),sin(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p),u=f*f*f*(f*(f*6.-15.)+10.);
return .5+.7*mix(mix(dot(g2(i),f),dot(g2(i+vec2(1,0)),f-vec2(1,0)),u.x),mix(dot(g2(i+vec2(0,1)),f-vec2(0,1)),dot(g2(i+1.),f-1.),u.x),u.y);}
vec2 curl(vec2 q){float e=.01;float dx=vn(q+vec2(e,0))-vn(q-vec2(e,0)),dy=vn(q+vec2(0,e))-vn(q-vec2(0,e));return vec2(dy,-dx)/(2.*e);}`;

// INIT fills the dye once: for an arrival, soft drops of two octaves at
// half and 1.1 times the grain, thresholded softly and given a density
// inside by a finer noise, so it starts disordered and the drops have an
// inside for the shading to show; for a departure, the seed itself. The colour is the seed's throughout
// (white for a word, the picture for a picture). STEP advects the dye by
// the curl of the noise, diffuses it a little through its four
// neighbours so a stretched filament thins, fades it so the haze clears,
// and relaxes it by the pull: toward the seed on an arrival, toward
// nothing on a departure.
// SHOW treats a word's dye as a concentration and shows it as absorbance
// over the ground (Beer-Lambert, written premultiplied, so pools go
// deeper than the token and washes are pale tints of it), darkened at
// the rim where it exceeds a ring around it, with the paper's tooth in
// the washes, and the core blended toward the ink token when asked;
// over a dark ground, where absorbance is invisible, an opacity ramp in
// the token. Faded by the harness's ease at the end. A picture's dye is painted
// over the page's ground and faded toward the picture itself, so the
// canvas is the picture when it comes off, and clipped to the
// picture's own box so a caption in the host stays visible.
const PROGRAMS: Record<string, string> = {
  init: `uniform sampler2D seed;uniform vec2 asp;uniform float grain,from,k;${NOISE}
void main(){vec4 s=texture(seed,v);vec2 p=v*asp/grain+k;float b=smoothstep(.42,.66,vn(p*.5)*.62+vn(p*1.13+3.1)*.38)*(.7+.6*vn(p*2.3+9.));o=from>.5?s:vec4(s.rgb,b);}`,
  step: `uniform sampler2D s,seed;uniform vec2 asp,tx;uniform float scale,drift,time,stir,pull,from,oct,dif,keep,fl;${NOISE}
void main(){vec2 c=v*asp*scale+drift*time,q=v-(curl(c)+oct*.5*curl(c*2.03+17.))*stir*vec2(1./asp.x,1.);vec4 a=texture(s,q);a+=dif*(texture(s,q+vec2(tx.x,0))+texture(s,q-vec2(tx.x,0))+texture(s,q+vec2(0,tx.y))+texture(s,q-vec2(0,tx.y))-4.*a);a.a=max(a.a*keep-fl,0.);o=mix(a,from>.5?vec4(0):texture(seed,v),pull);}`,
  show: `uniform sampler2D s,seed;uniform vec3 col,ink,uGround;uniform vec2 tx;uniform float ease,pic,dens,rim,gran,core,dark;${NOISE}
void main(){vec4 d=texture(s,v);if(pic>.5){vec4 k=texture(seed,v);vec3 c=mix(mix(uGround,d.rgb,d.a),k.rgb,1.-ease);o=vec4(c*k.a,k.a);}else{float r=0.,c=d.a;for(int i=0;i<8;i++){float t=float(i)*.785;r+=texture(s,v+vec2(cos(t),sin(t))*tx*3.).a;}r/=8.;c*=1.+gran*(vn(gl_FragCoord.xy*.35)-.5)*(1.-c);c*=1.+rim*clamp((c-r)*4.,0.,1.);vec3 K=mix(log(max(col,vec3(.02))),log(max(ink,vec3(.02))),core*smoothstep(.3,1.,c));float e=c*dens*ease;if(dark>.5){vec3 lc=mix(col,vec3(1),.25*clamp(c-1.,0.,1.));o=vec4(lc,1)*(1.-exp(-e));}else{vec3 T=exp(K*e);float a=1.-min(min(T.r,T.g),T.b);o=vec4(uGround*(T-1.+a),a);}}}`,
};

/** CSS tokens read into `kit.tokens` by these uniform names, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uInk', '--ink', '#000'],
  ['uAccent', '--accent', '#00f'],
  ['uGround', '--ground', '#fff'],
];

/** Dye is half float where the kit has a complete float target, else 8 bits; the recipe runs on both. */
const NEEDS_FLOAT = false;
export const dyeKind = (float: boolean): 0 | 1 => (float ? 1 : 0);
/** A fade by multiplication stalls in 8 bits, so that path also subtracts half a level a step. */
export const dyeFloor = (float: boolean): number => (float ? 0 : 1 / 510);

const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
const whole = (value: number, low: number, high: number): number => clamp(Math.round(Number.isFinite(value) ? value : low), low, high);

export interface Dials {
  stir: number;
  scale: number;
  drift: number;
  pull: number;
  grain: number;
  steps: number;
  octaves: number;
  soften: number;
  fade: number;
  depth: number;
  rim: number;
  gran: number;
  core: number;
  live: number;
  tint: number;
  direction: number;
}

/** Every dial inside the range the shaders survive. */
export function dials(o: Options): Dials {
  return {
    stir: clamp(o.stir, 0.001, 0.01),
    scale: clamp(o.scale, 1.5, 8),
    drift: clamp(o.drift, 0, 0.2),
    pull: clamp(o.pull, 0.1, 1),
    grain: clamp(o.grain, 0.02, 0.3),
    steps: whole(o.steps, 1, 4),
    octaves: whole(o.octaves, 1, 2),
    soften: clamp(o.soften, 0, 0.1),
    fade: clamp(o.fade, 0, 0.01),
    depth: clamp(o.depth, 1, 3),
    rim: clamp(o.rim, 0, 0.5),
    gran: clamp(o.gran, 0, 0.3),
    core: whole(o.core, 0, 1),
    live: clamp(o.live, 0, 0.1),
    tint: whole(o.tint, 0, 1),
    direction: whole(o.direction, 0, 1),
  };
}

/**
 * The forcing at progress `p`: the pull rises as the cube of progress,
 * toward the seed on an arrival and toward empty on a departure
 * (advection conserves the dye, so a departure must relax toward
 * nothing or the word never leaves); the stir is steady on an arrival
 * and rises from nothing on a departure, so the word is whole at first.
 */
export function envelope(p: number, direction: number, stir: number, pull: number): { stir: number; pull: number } {
  return { stir: direction === 1 ? stir * p : stir, pull: pull * p ** 3 };
}

/**
 * The frame's pull with `live` held: a fixed field under a constant pull
 * contracts to one image, so a mode that never settles needs the pull
 * held below the resolve's and the field moving (the Live version raises
 * `drift`). Legibility is the stir-to-pull ratio alone, 0.05 to 0.13.
 */
export const heldPull = (pull: number, live: number, direction: number): number => (live > 0 && direction === 0 ? Math.min(pull, live) : pull);

/**
 * How many steps this frame owes, at `steps` a frame of a 60 Hz screen:
 * the fraction accrues across frames, so a 240 Hz screen takes one step
 * in four frames and a 30 Hz one takes two, never more than four. The
 * research counted steps, not frames, and a stir per step would
 * otherwise quadruple on a fast screen.
 */
export function stepsDue(acc: number, steps: number, dt: number): { n: number; acc: number } {
  if (!(dt > 0)) return { n: 0, acc };
  const owed = acc + steps * dt * 60;
  const n = Math.min(4, Math.floor(owed));
  return { n, acc: owed - n };
}

/**
 * Running time since the seed over what the budget has left after the
 * wait for it, held between 0 and 1, so a slow font does not squeeze
 * the resolve into its tail; a budget of 0 counts as four seconds, and
 * at least half a second is always left.
 */
export const progressOf = (seconds: number, budget: number, waited: number): number =>
  clamp(seconds / Math.max(0.5, (budget > 0 ? budget : 4000) / 1000 - waited), 0, 1);

/** A picture that has loaded with a size; an unloaded one would upload as an empty texture. */
export const pictureReady = (img: HTMLImageElement): boolean => img.complete && img.naturalWidth > 0;

/** The crop of a picture that object-fit cover shows in a box, centred. */
export function coverCrop(naturalWidth: number, naturalHeight: number, boxWidth: number, boxHeight: number): { sx: number; sy: number; sw: number; sh: number } {
  const scale = Math.max(boxWidth / naturalWidth, boxHeight / naturalHeight);
  const sw = Math.round(boxWidth / scale);
  const sh = Math.round(boxHeight / scale);
  return { sx: Math.round((naturalWidth - sw) / 2), sy: Math.round((naturalHeight - sh) / 2), sw, sh };
}

/** A ground token whose relative luminance is under a half: ink over it must scatter, not absorb. */
export const groundIsDark = (rgb: readonly number[]): boolean => 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] < 0.5;

/** The first [data-mask] in the element, else its first heading, else its first picture. */
export function markOf(element: HTMLElement): HTMLElement | null {
  return element.querySelector<HTMLElement>('[data-mask]') ?? element.querySelector<HTMLElement>('h1, h2') ?? element.querySelector<HTMLElement>('img');
}

/** The extent of one line of text from its fragments, or null when they sit on more than one line or there is no text. */
export function lineOf(rects: readonly DOMRect[]): DOMRect | null {
  const parts = rects.filter((r) => r.width > 0);
  const first = parts[0];
  if (!first) return null;
  let left = first.left, right = first.right, top = first.top, bottom = first.bottom;
  for (const r of parts) {
    if (Math.abs(r.top - first.top) > 1) return null;
    left = Math.min(left, r.left);
    right = Math.max(right, r.right);
    top = Math.min(top, r.top);
    bottom = Math.max(bottom, r.bottom);
  }
  return new DOMRect(left, top, right - left, bottom - top);
}

/** The element's box and the one line of text in the mark, measured through a Range, or null. */
export function measureMark(element: HTMLElement, mark: HTMLElement): { box: DOMRect; rect: DOMRect } | null {
  const box = element.getBoundingClientRect();
  if (!(box.width > 0 && box.height > 0)) return null;
  const range = document.createRange();
  range.selectNodeContents(mark);
  const rect = lineOf(Array.from(range.getClientRects()));
  return rect ? { box, rect } : null;
}

/** The text as the page renders it: whitespace collapsed, the text-transform applied in the page's language. */
export function maskText(text: string, transform: string, lang: string): string {
  const flat = text.replace(/\s+/g, ' ').trim();
  if (transform === 'uppercase') return flat.toLocaleUpperCase(lang);
  if (transform === 'lowercase') return flat.toLocaleLowerCase(lang);
  if (transform === 'capitalize') return flat.replace(/(^|\s)(\S)/g, (_m, s: string, c: string) => s + c.toLocaleUpperCase(lang));
  return flat;
}

/** Where the baseline of one line sits: the top, half the slack, then the ascent. */
export const baseline = (top: number, height: number, ascent: number, descent: number): number =>
  top + (height - (ascent + descent)) / 2 + ascent;

/** Settled fonts, or the wait, whichever is first. */
export function fontsReady(ms: number): Promise<void> {
  const ready = document.fonts?.ready;
  if (!ready) return Promise.resolve();
  return Promise.race([ready.then(() => undefined), new Promise<void>((resolve) => setTimeout(resolve, ms))]);
}

/** How long to wait for the fonts before drawing on whatever face is in use. */
const FONT_WAIT = 1500;

const makeSim = (kit: Kit, first: Options): Sim => {
  let options = first;
  let d = dials(first);
  const host = kit.host;
  const mark = host ? markOf(host) : null;
  if (!host || !mark) throw new Error('no mark');
  const pic = mark instanceof HTMLImageElement;
  // A text mark that is empty or wraps on the face in use is refused here, before anything is attached.
  if (!pic && (!maskText(mark.textContent ?? '', getComputedStyle(mark).textTransform, 'en') || !measureMark(host, mark))) throw new Error('no one-line mark');
  const size = (): [number, number] => {
    const long = clamp(Math.round(options.simSize), 64, 2048);
    return kit.aspect >= 1 ? [long, Math.max(2, Math.round(long / kit.aspect))] : [Math.max(2, Math.round(long * kit.aspect)), long];
  };
  let [w, h] = size();
  let dye = kit.pair(w, h, dyeKind(kit.float), true, false);
  // The seed is drawn in the first frame it can be: a word once the
  // fonts settle, a picture once it has loaded. Frames before that draw
  // nothing, and the resolve fits into what the budget has left.
  let seed: WebGLTexture | null = null;
  let ready = pic ? pictureReady(mark as HTMLImageElement) : false;
  if (pic) {
    if (!ready) (mark as HTMLImageElement).addEventListener('load', () => { ready = true; }, { once: true });
  } else {
    fontsReady(FONT_WAIT).then(() => { ready = true; });
  }
  let seeded = false;
  let failed = false;
  let t0 = 0;
  let waited = 0;
  let ease = 1;
  let acc = 0;
  /** The picture cropped as object-fit cover shows it, drawn at the simulation's size where the img sits in the host; transparent elsewhere. */
  const drawPicture = (): WebGLTexture => {
    const img = mark as HTMLImageElement;
    const box = host.getBoundingClientRect();
    const rect = img.getBoundingClientRect();
    if (!(box.width > 0 && box.height > 0 && rect.width > 0 && rect.height > 0)) throw new Error('no box');
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('no 2d');
    const crop = coverCrop(img.naturalWidth, img.naturalHeight, rect.width, rect.height);
    ctx.imageSmoothingQuality = 'high';
    ctx.scale(w / box.width, h / box.height);
    ctx.drawImage(img, crop.sx, crop.sy, crop.sw, crop.sh, rect.left - box.left, rect.top - box.top, rect.width, rect.height);
    return kit.image(canvas, true);
  };
  /** The word drawn white on transparent at the simulation's size, where the page draws it. */
  const drawWord = (): WebGLTexture => {
    const measured = measureMark(host, mark);
    if (!measured) throw new Error('no one-line mark');
    const { box, rect } = measured;
    const style = getComputedStyle(mark);
    const lang = mark.closest<HTMLElement>('[lang]')?.lang || document.documentElement.lang || 'en';
    const text = maskText(mark.textContent ?? '', style.textTransform, lang);
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('no 2d');
    ctx.scale(w / box.width, h / box.height);
    const unset = ctx.font;
    ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
    if (ctx.font === unset) throw new Error('font');
    const text2d = ctx as CanvasRenderingContext2D & { letterSpacing?: string; fontStretch?: CanvasFontStretch };
    if ('letterSpacing' in text2d) text2d.letterSpacing = style.letterSpacing;
    if ('fontStretch' in text2d) text2d.fontStretch = style.fontStretch as CanvasFontStretch;
    ctx.textAlign = 'left';
    ctx.textBaseline = 'alphabetic';
    ctx.fillStyle = '#fff';
    const metrics = ctx.measureText(text);
    ctx.fillText(text, rect.left - box.left, baseline(rect.top - box.top, rect.height, metrics.fontBoundingBoxAscent, metrics.fontBoundingBoxDescent));
    return kit.image(canvas, true);
  };
  const init = (): void => {
    if (!seed) return;
    kit.pass(kit.program('init'), { seed: { tex: seed }, asp: [kit.aspect, 1], grain: d.grain, from: d.direction, k: Math.random() * 100 }, dye.w);
    dye.swap();
    seeded = true;
  };
  return {
    step(dt, o, input) {
      options = o;
      d = dials(o);
      ease = input.ease;
      if (failed) return false;
      if (!seeded) {
        if (!ready) return true;
        // A word that wraps once the real font is in, a picture from
        // another origin, a font the canvas cannot parse: the harness's
        // promise is that nothing throws, so the loop rests instead.
        try {
          if (!seed) seed = pic ? drawPicture() : drawWord();
          init();
        } catch {
          failed = true;
          return false;
        }
        t0 = input.time;
        waited = t0;
      }
      if (!seed) return true;
      const p = progressOf(input.time - t0, o.budget, waited);
      const e = envelope(p, d.direction, d.stir, d.pull);
      const held = heldPull(e.pull, d.live, d.direction);
      const due = stepsDue(acc, input.late ? Math.max(1, Math.ceil(d.steps / 2)) : d.steps, dt);
      acc = due.acc;
      const n = due.n;
      if (n === 0) return true;
      // The pull is per step, like the stir: steps are counted in time,
      // so a step on a 30 Hz screen is the same step as on a 240 Hz one.
      // A frame's pull split over its steps (the first build) halved the
      // pull per step at 30 Hz and doubled the stir-to-pull ratio, which
      // is what legibility is, past the edge where the word is lost.
      const pull = held;
      for (let i = 0; i < n; i += 1) {
        kit.pass(
          kit.program('step'),
          { s: { tex: dye.r.tex }, seed: { tex: seed }, asp: [kit.aspect, 1], tx: [1 / w, 1 / h], scale: d.scale, drift: d.drift, time: input.time, stir: e.stir, pull, from: d.direction, oct: d.octaves - 1, dif: d.soften, keep: 1 - d.fade, fl: dyeFloor(kit.float) },
          dye.w,
        );
        dye.swap();
      }
      return true;
    },
    show(o, tokens) {
      if (!seeded || !seed) return;
      d = dials(o);
      const ground = tokens['uGround'] ?? [1, 1, 1];
      kit.pass(
        kit.program('show'),
        { s: { tex: dye.r.tex }, seed: { tex: seed }, col: tokens[d.tint === 1 ? 'uInk' : 'uAccent'] ?? [0, 0, 1], ink: tokens['uInk'] ?? [0, 0, 0], uGround: ground, tx: [1 / w, 1 / h], ease, pic: pic ? 1 : 0, dens: d.depth, rim: d.rim, gran: d.gran, core: d.core, dark: groundIsDark(ground) ? 1 : 0 },
        null,
      );
    },
    update(previous, next) {
      options = next;
      d = dials(next);
      if (previous.simSize !== next.simSize) {
        kit.drop(dye);
        [w, h] = size();
        dye = kit.pair(w, h, dyeKind(kit.float), true, false);
        if (seed) {
          kit.drop(seed);
          seed = null;
        }
        seeded = false;
      } else if (previous.direction !== next.direction || previous.grain !== next.grain) {
        seeded = false;
      }
    },
  };
};

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Depart)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Resolve: {},
  Slow: { stir: 0.002, pull: 0.15, budget: 5000, scale: 2 },
  Turbulent: { stir: 0.008, scale: 6, drift: 0.012 },
  Depart: { direction: 1 },
  Picture: { grain: 0.15, pull: 0.35 },
  Live: { live: 0.04, drift: 0.05, budget: 0 },
  'Blue-black': { core: 1 },
};

// ---- HARNESS stateful: begin ------------------------------------------------
//
// Identical in every stateful 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
// maxPixelRatio, maxPixels, budget, paused, keepStill and simSize),
// DEFAULTS, Handle, PROGRAMS (fragment bodies, or { vertex, fragment }
// for a program with its own vertex shader), TOKENS, NEEDS_FLOAT and
// makeSim. The kit draws passes and points, and makes RGBA8, RGBA16F
// and RGBA32F targets (kinds 0, 1 and 2; the last is never filtered).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It runs no
// frame while off screen, hidden, paused or at rest. When the budget ends
// it, or on destroy, nothing of it remains but a kept still.

const VERTEX = `#version 300 es
out vec2 v;void main(){vec2 p=vec2(gl_VertexID<<1&2,gl_VertexID&2);v=p;gl_Position=vec4(p*2.-1.,0,1);}`;
const HEAD = `#version 300 es
precision highp float;precision highp sampler2D;in vec2 v;out vec4 o;`;

/** A program is a fragment body under the shared head, or a pair with its own vertex shader. */
type Programs = Record<string, string | { vertex: string; fragment: string }>;

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

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

export interface Target {
  tex: WebGLTexture;
  fbo: WebGLFramebuffer;
  w: number;
  h: number;
  /** Whether the framebuffer is complete: the real feature test for the format. */
  ok: boolean;
}
export interface Pair {
  /** Read this one. */
  r: Target;
  /** Write that one, then swap. */
  w: Target;
  swap(): void;
}
export type Uniform = number | readonly number[] | { tex: WebGLTexture };
export interface Program {
  p: WebGLProgram;
  u: Map<string, WebGLUniformLocation | null>;
}
export type Rgb = [number, number, number];
/** What the harness hands a recipe to build and run its simulation with. */
export interface Kit {
  gl: WebGL2RenderingContext;
  /** Whether an RGBA16F target is complete here. */
  float: boolean;
  /** Width over height of the host when the simulation is built. */
  aspect: number;
  /** The element the recipe is mounted on, for what it may read from the page. */
  host: HTMLElement | null;
  /** The token colours, by uniform name, read when the canvas is attached. */
  tokens: Record<string, Rgb>;
  program(name: string): Program;
  /** kind 0 is RGBA8, 1 is RGBA16F, 2 is RGBA32F (never filtered). */
  target(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Target;
  pair(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Pair;
  /** An RGBA8 texture from a canvas or an image, flipped so y is up. */
  image(source: TexImageSource, linear: boolean): WebGLTexture;
  /** One full-target pass. `dst` null draws to the canvas. Textures are bound in the order given. */
  pass(program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void;
  /** A POINTS draw of `count` vertices, added into `dst` or the canvas, blending on for this draw only. */
  points(program: Program, uniforms: Record<string, Uniform>, count: number, dst: Target | null): void;
  /** Deletes a target, a pair or a texture before its time. */
  drop(made: Target | Pair | WebGLTexture): void;
}
export interface Input {
  /** Pointer within the host, 0 to 1, y up. */
  x: number;
  y: number;
  /** True while the pointer moved over the host within the last 100 ms. */
  active: boolean;
  /** Whether a pointer has ever been over the host. */
  seen: boolean;
  /** Seconds since the last pointer event. Infinity if none, or it left. */
  idle: number;
  /** 1 while running, falling to 0 over the last half second of the budget. Scale the work a frame by it. */
  ease: number;
  /** Seconds of running time. */
  time: number;
  /** True once frames have come late: do about half the work a frame. */
  late: boolean;
}
export interface Sim {
  /** Runs the simulation forward by `dt` seconds. False when there is nothing left to draw: the harness then rests until input or an update. */
  step(dt: number, options: Options, input: Input): boolean;
  /** Draws the state to the canvas. */
  show(options: Options, tokens: Record<string, Rgb>): void;
  /** The host's size changed, in CSS pixels. */
  resize?(width: number, height: number): void;
  /** Options changed while mounted. */
  update?(previous: Options, next: Options): void;
  /** True while the pointer can bring it back: the budget then ends in rest, not release, and a touch restarts the clock. */
  listens?(options: Options): boolean;
}

/** 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))];
}

/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): Rgb | 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' ||
    typeof IntersectionObserver === '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();
  let options = mergeOptions(DEFAULTS, explicit);

  const page = element === document.body || element === document.documentElement;
  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:2147483647`;

  let gl: WebGL2RenderingContext | null = null;
  let kit: Kit | null = null;
  let sim: Sim | null = null;
  let observer: ResizeObserver | null = null;
  let seer: IntersectionObserver | null = null;
  let still: HTMLCanvasElement | null = null;
  let hostStyle: string | null | undefined;
  const programs = new Map<string, Program>();
  const made: (() => void)[] = [];
  let frame = 0;
  let timer = 0;
  let slow = 0;
  let last = 0;
  let elapsed = 0;
  let built = false;
  let done = false;
  let visible = document.visibilityState === 'visible';
  let onScreen = true;
  let pointerAt = -Infinity;
  const input: Input = { x: 0.5, y: 0.5, active: false, seen: false, idle: Infinity, ease: 1, time: 0, late: false };

  /** Ends the effect for good. 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();
    seer?.disconnect();
    document.removeEventListener('visibilitychange', wake);
    removeEventListener('pagehide', stop);
    removeEventListener('pageswap', stop);
    canvas.removeEventListener('webglcontextlost', stop);
    for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.removeEventListener(type, pointer);
    if (keep && gl && built && 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) {
      for (const free of made) free();
      for (const program of programs.values()) gl.deleteProgram(program.p);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    }
    canvas.width = canvas.height = 0;
    gl = kit = sim = 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 = (): Record<string, Rgb> => {
    const out: Record<string, Rgb> = {};
    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%)`;
      out[uniform] = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
    }
    probe.color = '';
    return out;
  };

  const running = (): boolean => !done && built && visible && onScreen && !options.paused;
  const schedule = (): void => {
    if (frame === 0 && running()) frame = requestAnimationFrame(draw);
  };
  function draw(now: number): void {
    frame = 0;
    if (!running() || !gl || !kit || !sim) return;
    const gap = last ? Math.min(now - last, 100) : 0;
    last = now;
    const dt = gap / 1000;
    elapsed += gap;
    input.time += dt;
    // Four frames in a row slower than 15 a second: do less from now on.
    slow = gap > 66 ? slow + 1 : 0;
    if (slow > 3) input.late = true;
    const left = options.budget > 0 ? options.budget - elapsed : Infinity;
    input.ease = Math.max(0, Math.min(1, left / 500));
    input.idle = (now - pointerAt) / 1000;
    input.active = input.idle < 0.1;
    const more = sim.step(dt, options, input);
    sim.show(options, kit.tokens);
    const listens = sim.listens?.(options) ?? false;
    if (left <= 0 && !listens) return finish(options.keepStill);
    if (more && left > 0) schedule();
    else {
      // At rest: nothing to draw until something comes in. A recipe that
      // listens for the pointer rests at its budget too, so the pointer
      // can bring it back.
      canvas.dataset.state = 'rest';
      last = 0;
    }
  }
  /**
   * Something came in: a pointer, an update, the page back on screen.
   * The frame clock starts afresh only from rest: reset on every event,
   * a pointer that never stops (a mouse at 125 Hz or more) gave every
   * frame a step of nothing, and the field froze under it.
   */
  const wakeLoop = (): void => {
    if (!built || done) return;
    canvas.dataset.state = 'running';
    if (frame === 0) last = 0;
    schedule();
  };

  const pointer = (event: PointerEvent): void => {
    if (event.type === 'pointerleave') {
      pointerAt = -Infinity;
      return;
    }
    const box = element.getBoundingClientRect();
    input.x = (event.clientX - box.left) / Math.max(1, box.width);
    input.y = 1 - (event.clientY - box.top) / Math.max(1, box.height);
    input.seen = true;
    pointerAt = performance.now();
    // A touch is a fresh start for the clock of a recipe that answers it.
    if (sim?.listens?.(options)) elapsed = 0;
    wakeLoop();
  };

  const resize = (entries: ResizeObserverEntry[]): void => {
    const entry = entries[0];
    if (done || !gl || !sim || !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;
    }
    sim.resize?.(cssWidth, cssHeight);
    // Sizing clears the buffer, so draw before this frame is shown.
    if (kit) sim.show(options, kit.tokens);
    schedule();
  };

  const makeKit = (g: WebGL2RenderingContext): Kit => {
    const filter = (tex: WebGLTexture, linear: boolean, repeat: boolean): void => {
      g.bindTexture(g.TEXTURE_2D, tex);
      const f = linear ? g.LINEAR : g.NEAREST;
      const r = repeat ? g.REPEAT : g.CLAMP_TO_EDGE;
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, f);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, f);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, r);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, r);
    };
    const drop: Kit['drop'] = (thing) => {
      if ('r' in thing) {
        drop(thing.r);
        drop(thing.w);
      } else if ('fbo' in thing) {
        g.deleteFramebuffer(thing.fbo);
        g.deleteTexture(thing.tex);
      } else {
        g.deleteTexture(thing);
      }
    };
    /** Destination, viewport, program and uniforms for a draw; textures take units in the order given. */
    const bind = (program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void => {
      g.bindFramebuffer(g.FRAMEBUFFER, dst ? dst.fbo : null);
      g.viewport(0, 0, dst ? dst.w : canvas.width, dst ? dst.h : canvas.height);
      g.useProgram(program.p);
      let unit = 0;
      for (const [name, value] of Object.entries(uniforms)) {
        if (!program.u.has(name)) program.u.set(name, g.getUniformLocation(program.p, name));
        const at = program.u.get(name) ?? null;
        if (typeof value === 'number') g.uniform1f(at, value);
        else if ('tex' in value) {
          g.activeTexture(g.TEXTURE0 + unit);
          g.bindTexture(g.TEXTURE_2D, value.tex);
          g.uniform1i(at, unit);
          unit += 1;
        } else if (value.length === 2) g.uniform2fv(at, value);
        else if (value.length === 3) g.uniform3fv(at, value);
        else g.uniform4fv(at, value);
      }
    };
    const target: Kit['target'] = (w, h, kind, linear, repeat) => {
      const tex = g.createTexture();
      const fbo = g.createFramebuffer();
      g.bindTexture(g.TEXTURE_2D, tex);
      g.texStorage2D(g.TEXTURE_2D, 1, kind === 2 ? g.RGBA32F : kind === 1 ? g.RGBA16F : g.RGBA8, w, h);
      filter(tex, linear && kind !== 2, repeat);
      g.bindFramebuffer(g.FRAMEBUFFER, fbo);
      g.framebufferTexture2D(g.FRAMEBUFFER, g.COLOR_ATTACHMENT0, g.TEXTURE_2D, tex, 0);
      const ok = g.checkFramebufferStatus(g.FRAMEBUFFER) === g.FRAMEBUFFER_COMPLETE;
      if (ok) {
        g.clearColor(0, 0, 0, 0);
        g.clear(g.COLOR_BUFFER_BIT);
      }
      const out = { tex, fbo, w, h, ok };
      made.push(() => drop(out));
      return out;
    };
    // Float capability is the completeness of a real target, never the
    // extension list: WebKit has shipped releases that advertised what
    // the hardware did not have.
    g.getExtension('EXT_color_buffer_float');
    const probe = target(2, 2, 1, true, false);
    const float = probe.ok;
    drop(probe);
    return {
      gl: g,
      float,
      aspect: 1,
      host: null,
      tokens: {},
      program: (name) => {
        const program = programs.get(name);
        if (!program) throw new Error(name);
        return program;
      },
      target,
      pair: (w, h, kind, linear, repeat) => {
        const pair: Pair = {
          r: target(w, h, kind, linear, repeat),
          w: target(w, h, kind, linear, repeat),
          swap() {
            const t = pair.r;
            pair.r = pair.w;
            pair.w = t;
          },
        };
        return pair;
      },
      image: (source, linear) => {
        const tex = g.createTexture();
        g.bindTexture(g.TEXTURE_2D, tex);
        g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, true);
        g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, source);
        g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, false);
        filter(tex, linear, false);
        made.push(() => g.deleteTexture(tex));
        return tex;
      },
      pass: (program, uniforms, dst) => {
        bind(program, uniforms, dst);
        g.drawArrays(g.TRIANGLES, 0, 3);
      },
      points: (program, uniforms, count, dst) => {
        bind(program, uniforms, dst);
        g.enable(g.BLEND);
        g.blendFunc(g.ONE, g.ONE);
        g.drawArrays(g.POINTS, 0, count);
        g.disable(g.BLEND);
      },
      drop,
    };
  };

  const ready = (): void => {
    if (done || !gl) return;
    for (const program of programs.values()) {
      if (!gl.getProgramParameter(program.p, gl.LINK_STATUS)) return stop();
    }
    kit = makeKit(gl);
    if (NEEDS_FLOAT && !kit.float) return stop();
    const box = element.getBoundingClientRect();
    kit.aspect = box.width / Math.max(1, box.height);
    kit.host = element;
    try {
      sim = makeSim(kit, options);
    } catch {
      return stop();
    }
    if (!page && getComputedStyle(element).position === 'static') {
      // An absolutely placed canvas covers the nearest positioned box:
      // the host, not the page.
      hostStyle = element.getAttribute('style');
      element.style.position = 'relative';
    }
    element.append(canvas);
    canvas.dataset.state = 'running';
    built = true;
    // In the document first: an element outside it has no computed style.
    kit.tokens = tokens();
    observer = new ResizeObserver(resize);
    try {
      observer.observe(canvas, { box: 'device-pixel-content-box' });
    } catch {
      observer.observe(canvas);
    }
    seer = new IntersectionObserver((entries) => {
      onScreen = entries.some((entry) => entry.isIntersecting);
      if (onScreen) wakeLoop();
      else last = 0;
    });
    seer.observe(element);
    for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.addEventListener(type, pointer);
  };

  const build = (): void => {
    if (done || gl) return;
    if (document.visibilityState !== 'visible') return;
    gl = canvas.getContext('webgl2', {
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      powerPreference: 'low-power',
      // A software renderer would burn a weak machine's cores for decoration.
      failIfMajorPerformanceCaveat: true,
    });
    if (!gl) return stop();
    const g = gl;
    canvas.addEventListener('webglcontextlost', stop);
    for (const [name, body] of Object.entries(PROGRAMS as Programs)) {
      const p = g.createProgram();
      const [vert, frag] = typeof body === 'string' ? [VERTEX, HEAD + body] : [body.vertex, HEAD + body.fragment];
      for (const [type, source] of [[g.VERTEX_SHADER, vert], [g.FRAGMENT_SHADER, frag]] as const) {
        const shader = g.createShader(type);
        if (!shader) return stop();
        g.shaderSource(shader, source);
        g.compileShader(shader);
        g.attachShader(p, shader);
        g.deleteShader(shader);
      }
      g.linkProgram(p);
      programs.set(name, { p, u: new Map() });
    }
    const parallel = g.getExtension('KHR_parallel_shader_compile');
    let waited = 0;
    const poll = (): void => {
      frame = 0;
      if (done || !gl) return;
      waited += 1;
      // With the extension, ask without blocking, and wait for the
      // slowest. 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.
      const linked = parallel
        ? [...programs.values()].every((program) => g.getProgramParameter(program.p, parallel.COMPLETION_STATUS_KHR))
        : waited > 2;
      if (linked) ready();
      else frame = requestAnimationFrame(poll);
    };
    frame = requestAnimationFrame(poll);
  };
  function wake(): void {
    visible = document.visibilityState === 'visible';
    if (!gl) build();
    else if (visible) wakeLoop();
    else last = 0;
  }

  const handle: Handle = {
    update(next) {
      const previous = options;
      options = mergeOptions(options, next);
      try {
        sim?.update?.(previous, options);
      } catch {
        return stop();
      }
      if (options.paused) last = 0;
      else wakeLoop();
    },
    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.
  frame = requestAnimationFrame(() => {
    frame = 0;
    timer = window.setTimeout(build, 0);
  });
  return handle;
}

// ---- HARNESS stateful: end --------------------------------------------------