atelier

Catalog / Recipe

Point gather

Points spread over a block gather onto the real heading inside it, land on it exactly, fade and leave; reversed, they lift off it as dust. The heading is never hidden.

Family
point system
Status
passed
Used for
HeroPage loadSection intro
Size
5,376 of 5,500 bytes, gzipped
Frame
Skin
Version
from
direction
blend
tint
trigger
mount(element)

The first point recipe, and the stateless one: every point is a pure function of its index, the progress and the time, worked out in the vertex shader from gl_VertexID with no vertex data. Its only state is the list of places on the word where the points land, sampled once from the word’s own text. The word is real text the page styled, and it is readable at every moment of the run, because the recipe only ever adds to it.

Use

import { mount } from './point-gather';

mount(document.querySelector('.hero'), { count: 40000 });

The element is a block the points may spread over: a hero, a lead, a band. The mark is the first [data-mask] inside it, else its first h1 or h2, and it must be one line. The recipe reads the mark’s font, size, letter spacing and position and never writes to it.

How it moves

  1. mount returns at once; nothing on the page changes.
  2. After the first paint the context is made and the program compiled without blocking, as in every one-shot recipe.
  3. Prepare: the fonts are waited for (at most 1.5 s), the word is drawn on a 2D canvas where the page draws it, with the half-leading baseline from measureText, its inked pixels are read back, count of them are picked, and they go to the GPU as one RG32F texture.
  4. The canvas is attached and the clock starts (or waits for the block to scroll into view, with Intro). Each point sets off at its own moment within stagger, eases to its target, wanders a little on the way with a phase of its own, and grows in flight.
  5. The points fade over the last tenth; the canvas is removed and the context released. The word was there throughout.

Options

Option Default What it does
count 30000 Points. Never more than the word has inked pixels
size 2 A landed point, in CSS px
travelSize 1.5 Growth in flight, as a multiple of size; lowered so no point exceeds 64 buffer px
stagger 0.45 The share of the run over which points set off
wander 0.04 Sideways drift in flight, as a share of the box
strength 1 Peak opacity; 0.2 to 0.4 with add
from 0 0 anywhere in the box, 1 a burst around the word, 2 the leading edge
blend 0 0 over, 1 add. Add is for a dark ground
tint 0 0 accent, 1 ink
direction 0 0 gather, 1 scatter
trigger 0 0 on load, 1 when the block enters the viewport
duration 1400 ms; falls back to --load-duration
maxPixelRatio 1.5 Point cost grows with the square of this
arrivalOnly true Only on an arrival from outside the site

Versions

Version What it is
Gather A fog over the block condenses onto the word
Burst An exhale reversed: from around the word, larger in flight
Wind From the leading edge, in a trickle
Dust The word lifts off as dust
Glow Additive, for a dark ground
Intro Starts when the block scrolls into view; for a section heading below the fold

Where it must not go

Not on body text or small type: at small sizes a dot or a breve is a few points. Not over data, a listing or a card grid, where the fog reads as damage. Not as a background, because it ends. Glow on a light ground has nothing to glow against.

What it does when things are missing

Situation What the reader sees
No WebGL2, a software renderer, a lost context The page as served
No mark, an empty mark, a mark on more than one line The page as served; nothing is attached
Fonts late After 1.5 s the mask is drawn on the face in use; a misregistration, not a broken page
Reduced motion, forced colours The page as served
No IntersectionObserver with Intro Starts at once
After the run, or on destroy The page as served, the host’s style as it was

Learned while building it

  • A uniform both shaders declare must have one precision in each. The fragment shader was mediump and the vertex shader highp, and the program did not link, silently: the harness ends with nothing shown. The browser suite caught it; no unit test can.
  • A float texture of targets needs nearest filtering on both filters. With the default linear magnification the RG32F texture is incomplete without an extension and reads as zero even through texelFetch, so every point landed in the bottom-left corner. A trim that dropped one texParameteri call cost the whole effect.
  • The band’s word is a grid item, so its one client rect is the line box, not the glyphs’ content area. With a line-height of 1 the descender of ğ and the cedilla of ş sit below that box, where the page draws them too; the browser check measures “on the word” against the ink, and holds the ink to the line box at its left, right and top.
  • A block mark has one client rect however many lines it wraps to, and it is the full column, not the letters. The first build measured the mark’s own rects and would have drawn a centred heading flush left and a wrapped one as a stripe through the middle. The text is measured through a Range over the mark’s contents, which gives one fragment per line and the letters’ own extent.
  • The mask must be the text the page renders, not the source: a heading set in capitals by text-transform, or formatted across lines by a tool, misregisters otherwise. Whitespace is collapsed and the transform applied in the page’s language, so Turkish i and ı survive it.
  • The browser check first drew its reference mask the way the recipe draws its own, and so agreed with the recipe wherever the recipe put it. It now thresholds the page’s own glyphs out of a screenshot and measures both ways.
  • Intro first armed the clock on view with the canvas attached at mount, which sampled the word before the fonts and the layout were final and held a context below the fold. Now nothing is made until the block is in view.
  • On the bench, Replay and every change of version mounted nothing. The Intro version set arrivalOnly: false, so the demo gave arrivalOnly a control, and the panel’s default for it, true, went over the stage’s fixed arrivalOnly: false on every remount: inside a frame the catalog loaded, the recipe’s own rule then mounted nothing. The first load worked because the stage’s first mount carries no panel values. A control never sits on an option the demo fixes; a catalog test now holds every recipe to that.
  • The one-shot region grew about 140 bytes gzipped for prepare, the start on view and the positioned host. The recipe measured 5,379 bytes.
point-gather.ts747 lines · 5,376 bytes gzipped · no imports
/**
 * point-gather
 *
 * Points spread over a block gather onto the heading inside it, land on
 * it exactly, fade, and the canvas leaves. Reversed, they lift off the
 * heading as dust. The heading is real text the page styled; this file
 * only ever draws over it and never hides, moves or restyles it.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './point-gather';
 *     mount(document.querySelector('.hero'));
 *
 * The mark is the first [data-mask] inside the element, else its first
 * h1 or h2. It must be one line. Every point is a pure function of its
 * index, the progress and the time, computed in the vertex shader; the
 * only state is the list of places on the word the points land on,
 * sampled once from the word's own text and uploaded as a texture.
 */

export interface Options {
  /** How long the run takes, in ms. Falls back to --load-duration. */
  duration: number;
  /** Ceiling on canvas pixels per CSS pixel. Point cost grows with its square. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Play only when the reader arrived from outside the site. */
  arrivalOnly: boolean;
  /** How many points, 5,000 to 100,000. Never more than the word has inked pixels. */
  count: number;
  /** Size of a landed point in CSS px, 1 to 6. */
  size: number;
  /** Growth in flight, as a multiple of the size, 0 to 3. Lowered so no point exceeds 64 buffer px. */
  travelSize: number;
  /** Share of the run over which points set off, 0 to 0.8. Zero is a rigid morph. */
  stagger: number;
  /** Sideways drift in flight, as a share of the box, 0 to 0.1. */
  wander: number;
  /** Peak opacity, 0.2 to 1. Keep it at 0.2 to 0.4 with add. */
  strength: number;
  /** Where points start: 0 anywhere in the box, 1 a burst around the word, 2 the leading edge. */
  from: number;
  /** How points combine: 0 over, 1 add. Add is for a dark ground. */
  blend: number;
  /** Which token colours the points: 0 accent, 1 ink. */
  tint: number;
  /** 0 gather onto the word, 1 scatter off it. */
  direction: number;
  /** When the clock starts: 0 on load, 1 when the block enters the viewport. */
  trigger: number;
}

export const DEFAULTS: Options = {
  duration: 1400,
  maxPixelRatio: 1.5,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  count: 30000,
  size: 2,
  travelSize: 1.5,
  stagger: 0.45,
  wander: 0.04,
  strength: 1,
  from: 0,
  blend: 0,
  tint: 0,
  direction: 0,
  trigger: 0,
};

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

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

// Every point: its own start, its own moment to set off, its own ease
// to the target, and a wander whose phase is its own hash. At e = 1 it
// is at its target to the float: mix with weight 1 returns the target
// and the wander is multiplied by zero. A point past the count, or a
// shader that wants to hide one, moves it out of clip space; a zero
// size is not a reliable hide.
const VERTEX = `#version 300 es
precision highp float;precision highp int;
uniform float uProgress,uTime,uSeed,uPixelRatio,uCount,uSide;
uniform vec4 uDial,uLook,uMark;
uniform sampler2D uTargets;
out float vA;
uint hh(uint x){x^=x>>16u;x*=0x7feb352du;x^=x>>15u;x*=0x846ca68bu;x^=x>>16u;return x;}
float h(uint i,uint k){return float(hh(hh(i+uint(uSeed*65535.))+k*0x9e3779b9u))/4294967295.;}
void main(){
int i=gl_VertexID;uint u=uint(i);
if(float(i)>=uCount){gl_Position=vec4(2,2,2,1);gl_PointSize=1.;vA=0.;return;}
vec2 t=texelFetch(uTargets,ivec2(i%int(uSide),i/int(uSide)),0).xy;
vec2 s=vec2(h(u,0u),h(u,1u));
if(uLook.y>1.5)s.x=-.05-s.x*.1;
else if(uLook.y>.5){float a=h(u,0u)*6.2832,r=uMark.w*(.4+h(u,1u));s=t+vec2(cos(a),sin(a))*r;}
float p=uLook.w>.5?1.-uProgress:uProgress;
float st=uDial.z,e=clamp((p-h(u,2u)*st)/max(1.-st,.001),0.,1.);
e=1.-pow(1.-e,3.);
float w=1.-e,ph=h(u,3u)*6.2832;
vec2 q=mix(s,t,e)+vec2(sin(uTime*1.7+ph),cos(uTime*1.3+ph*1.7))*uDial.w*w;
gl_Position=vec4(q*2.-1.,0,1);
gl_PointSize=uDial.x*uPixelRatio*(1.+uDial.y*w);
vA=uLook.x*smoothstep(0.,.1,uProgress)*(1.-smoothstep(.9,1.,uProgress));
}`;

// A round dot, premultiplied: colour no greater than alpha, so over and
// add both keep a valid buffer. highp, because a uniform the two
// shaders share must have one precision or the program does not link.
const FRAGMENT = `#version 300 es
precision highp float;
uniform vec3 uInk,uAccent;
uniform vec4 uLook;
in float vA;
out vec4 o;
void main(){
float a=vA*(1.-smoothstep(.35,.5,length(gl_PointCoord-.5)));
if(a<.003)discard;
vec3 k=uLook.z<.5?uAccent:uInk;
o=vec4(k*a,a);
}`;

/** 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'],
];

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);

/** The largest point any GPU is trusted to draw, in buffer pixels. */
const MAX_POINT = 64;

export interface Dials {
  size: number;
  travelSize: number;
  stagger: number;
  wander: number;
  strength: number;
  from: number;
  blend: number;
  tint: number;
  direction: number;
  trigger: number;
}

/** Every dial inside the range the shader survives, and the size guard. */
export function dials(o: Options): Dials {
  const size = clamp(o.size, 1, 6);
  // The ratio as given: the harness uses it unclamped.
  const ratio = Math.max(0.25, Number.isFinite(o.maxPixelRatio) ? o.maxPixelRatio : 1.5);
  const growth = Math.max(0, MAX_POINT / (size * ratio) - 1);
  return {
    size,
    travelSize: clamp(o.travelSize, 0, Math.min(3, growth)),
    stagger: clamp(o.stagger, 0, 0.8),
    wander: clamp(o.wander, 0, 0.1),
    strength: clamp(o.strength, 0.2, 1),
    from: whole(o.from, 0, 2),
    blend: whole(o.blend, 0, 1),
    tint: whole(o.tint, 0, 1),
    direction: whole(o.direction, 0, 1),
    trigger: whole(o.trigger, 0, 1),
  };
}

/** How many points are drawn: a whole number, 5,000 to 100,000. */
export const countOf = (o: Options): number => whole(o.count, 5000, 100000);

/** The effect's own uniforms, made safe: the panel can send any number. */
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  const d = dials(o);
  gl.uniform4f(at('uDial'), d.size, d.travelSize, d.stagger, d.wander);
  gl.uniform4f(at('uLook'), d.strength, d.from, d.tint, d.direction);
};

/** It leaves nothing: the word was there all along. */
const keepsStill = (_options: Options): boolean => false;
/** The points fade themselves in. */
const fadeInMs = (_options: Options): number => 0;
/** Over the content, which stays readable through it. */
const layerUnder = (_options: Options): boolean => false;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** Points, one per index, with no vertex data. */
export const geometry = (gl: WebGL2RenderingContext, o: Options): [mode: number, count: number] => [gl.POINTS, countOf(o)];
/** No depth: points over a page. */
const DEPTH = false;
/** Intro waits for the block to scroll into view. */
const startsOnView = (o: Options): boolean => dials(o).trigger === 1;

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

/**
 * The extent of one line of text from its fragments (a styled child
 * makes more than one), or null when the fragments 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 so that a block mark (a heading) gives its text's
 * own extent, not its box: a centred heading is measured where its
 * letters are, and a heading on two lines gives 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. A page without a font set settles at once. */
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))]);
}

/** `n` pairs drawn without replacement from `positions`, the same for the same seed. */
export function pick(positions: Float32Array, n: number, seed: number): Float32Array {
  const total = positions.length >> 1;
  const take = Math.max(0, Math.min(n, total));
  const index = new Uint32Array(total);
  for (let i = 0; i < total; i += 1) index[i] = i;
  let state = (Math.floor(seed * 2147483647) >>> 0) || 1;
  const out = new Float32Array(take * 2);
  for (let i = 0; i < take; i += 1) {
    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
    const j = i + (state % (total - i));
    const a = index[i] as number;
    index[i] = index[j] as number;
    index[j] = a;
    const p = (index[i] as number) * 2;
    out[i * 2] = positions[p] as number;
    out[i * 2 + 1] = positions[p + 1] as number;
  }
  return out;
}

/** The mask is drawn no wider than this, in CSS px: the read-back is the main-thread cost. */
const MASK_WIDTH = 1200;
/** How long to wait for the fonts before drawing on whatever face is in use. */
const FONT_WAIT = 1500;

/**
 * Before the first frame: wait for the fonts, draw the word where the
 * page draws it, sample the inked pixels, upload them, set the blend.
 * Throws for a missing, empty or wrapped mark; the harness then shows
 * nothing.
 */
const prepare: Prepare = async (gl, at, element, o) => {
  await fontsReady(FONT_WAIT);
  const mark = markOf(element);
  const style = mark ? getComputedStyle(mark) : null;
  const lang = mark?.closest<HTMLElement>('[lang]')?.lang || document.documentElement.lang || 'en';
  const text = mark && style ? maskText(mark.textContent ?? '', style.textTransform, lang) : '';
  const measured = mark && text ? measureMark(element, mark) : null;
  if (!mark || !style || !measured) throw new Error('no one-line mark');
  const { box, rect } = measured;
  const scale = Math.min(1, MASK_WIDTH / box.width);
  const mask = document.createElement('canvas');
  mask.width = Math.max(1, Math.round(box.width * scale));
  mask.height = Math.max(1, Math.round(box.height * scale));
  const ctx = mask.getContext('2d', { willReadFrequently: true });
  if (!ctx) throw new Error('no 2d');
  ctx.scale(scale, scale);
  // A font the canvas cannot parse leaves the default in place, and a
  // mask in the default face lands nowhere near the word: stop instead.
  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 = '#000';
  const metrics = ctx.measureText(text);
  ctx.fillText(text, rect.left - box.left, baseline(rect.top - box.top, rect.height, metrics.fontBoundingBoxAscent, metrics.fontBoundingBoxDescent));
  const { width, height } = mask;
  const data = ctx.getImageData(0, 0, width, height).data;
  // Every inked pixel's centre, as 0 to 1 within the element with y up.
  const positions = new Float32Array(width * height * 2);
  let k = 0;
  for (let i = 3; i < data.length; i += 4) {
    if ((data[i] as number) <= 127) continue;
    const p = (i - 3) >> 2;
    positions[k++] = ((p % width) + 0.5) / width;
    positions[k++] = 1 - (Math.floor(p / width) + 0.5) / height;
  }
  const targets = pick(positions.subarray(0, k), countOf(o), Math.random());
  const n = targets.length >> 1;
  if (n === 0) throw new Error('nothing inked');
  const side = Math.ceil(Math.sqrt(n));
  const texels = new Float32Array(side * side * 2);
  texels.set(targets);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
  // Both filters nearest: a float texture with a linear filter is
  // incomplete without an extension, and an incomplete texture reads
  // as zero, silently, even through texelFetch. Every point then lands
  // in one corner.
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, side, side, 0, gl.RG, gl.FLOAT, texels);
  gl.uniform1i(at('uTargets'), 0);
  gl.uniform1f(at('uCount'), n);
  gl.uniform1f(at('uSide'), side);
  gl.uniform4f(at('uMark'), (rect.left - box.left) / box.width, 1 - (rect.bottom - box.top) / box.height, rect.width / box.width, rect.height / box.height);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, dials(o).blend === 1 ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);
};

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Burst)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Gather: {},
  Burst: { from: 1, travelSize: 2 },
  Wind: { from: 2, stagger: 0.6, wander: 0.07 },
  Dust: { direction: 1 },
  Glow: { blend: 1, strength: 0.3, size: 3 },
  Intro: { trigger: 1 },
};

// ---- 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 --------------------------------------------------