atelier

Catalog / Recipe

Plane arrive

A picture entering the screen is uncovered by a rough front from the edge it came in by, the picture stretched toward it and falling into place.

Family
plane
Status
lab
Used for
GalleryListingCard grid
Size
4,798 of 5,000 bytes, gzipped
Frame
Skin
Version
direction
mount(element)

A reveal with material in it. The lid shows the page’s ground until a front, bent by noise, sweeps across the picture from the edge it entered the screen by. Just behind the front the picture is pulled toward it and falls into place. At the end every term vanishes, the lid comes off, and the picture is the page’s own.

Use

import { mount } from './plane-arrive';

mount(document.querySelector('.gallery'));

How it moves

The front starts one band and one roughness before the box and ends the same past it, so the first frame is all ground and the last all picture. duration is the visible sweep: the ease is gentle enough that the number is honest. Images that come into view together are a row, and a row arrives whole or arrives plain. An image in view at mount is left plain: an arrival is for what comes in.

Options

Option Default Meaning
duration 500 The visible sweep, in ms
stagger 70 Delay between the images of a row
band 0.28 How wide the stretched band behind the front is
stretch 0.2 How far the band pulls the picture; 0 is a clean organic wipe
rough 0.1 How far noise bends the front; 0 is a straight edge
frequency 5 How many lobes the noise puts across the front
split 0 Colour split at the front. Garish on type; a taste
direction 0 The edge it entered by, or left, right, top, bottom
maxLive, maxPixelRatio, rescueMs, once 6, 2, 3000, true As every lid recipe

Versions

Version What it is
Sweep The defaults
Torn A rougher, busier front and a wider band
Clean A straight front and no stretch: an organic wipe
Fringed A colour split, for photographs

Where it must not go

  • Anything in view at load. A page that opens on a covered picture is a page that withheld it.
  • A figure inside running prose. Nothing moves beside the text unless the reader starts it.
  • Images with transparency, or on a ground that is not flat. The lid is opaque and paints the ground colour.
  • Charts and screenshots the reader must read. A stretched chart is a wrong chart for half a second.

Learned while building it

  • The ease decides the real duration. With a strong ease-out an 800 ms arrival was 94 percent done at half time. The duration is the visible sweep.
  • A row half treated is worse than either. When the pool cannot cover a whole row, the row arrives plain.
  • A row jumped to is entered before it is near. Inside an iframe the observer’s margin is not applied, and the two observers report in no order. Entering must not wait on near, and an image waiting for its bitmap is counted once, or a second row jumped to stays plain for the rest of the page.
  • Rounded corners show a hairline where the antialiased corner lets the picture through the ground colour. Cosmetic.
plane-arrive.ts688 lines · 4,798 bytes gzipped · no imports
/**
 * plane-arrive
 *
 * A picture entering the screen is uncovered by a rough front that
 * sweeps across it from the edge it came in by. Just behind the front
 * the picture is stretched toward it and falls into place: a reveal
 * with material in it, not a fade. On a lid over the real `img`, which
 * is never touched; the lid shows the page's ground until the front
 * arrives, and comes off when the picture is whole.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './plane-arrive';
 *     mount(document.querySelector('.gallery'));
 *
 * For galleries, listings and card grids. Not for an image in view at
 * load, and not for a figure inside prose.
 */

export interface Options {
  /** The visible sweep, in ms. Over about 500 reads as delay. */
  duration: number;
  /** Delay between the images of one group, in ms. */
  stagger: number;
  /** How many lids may be live at once, 1 to 6. A row larger than this arrives plain. */
  maxLive: number;
  /** Ceiling on lid pixels per CSS pixel, snapped to a whole fraction of the device's. */
  maxPixelRatio: number;
  /** If script stalls with a lid on, the compositor takes it off after this many ms. */
  rescueMs: number;
  /** Arrive once per page load, the first time the image is seen. */
  once: boolean;
  /** Width of the band behind the front where the picture is stretched, as a share of the box. */
  band: number;
  /** How far the band pulls the picture toward the front. 0 is a clean organic wipe. */
  stretch: number;
  /** How far noise bends the front. 0 is a straight edge. */
  rough: number;
  /** How many lobes the noise puts across the front. */
  frequency: number;
  /** Colour split at the front, as a share of the box. Garish on type; a taste. */
  split: number;
  /** Where the front comes from: 0 the edge the image entered by, 1 left, 2 right, 3 top, 4 bottom. */
  direction: number;
}

export const DEFAULTS: Options = {
  duration: 500,
  stagger: 70,
  maxLive: 6,
  maxPixelRatio: 2,
  rescueMs: 3000,
  once: true,
  band: 0.28,
  stretch: 0.2,
  rough: 0.1,
  frequency: 5,
  split: 0,
  direction: 0,
};

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

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

// `s` is how far along the direction of travel a pixel is, 0 to 1; `n`
// bends it by value noise; `e` is the front, which starts one band and
// one roughness before the box and ends the same past it, so the first
// frame is all ground and the last all picture. `m` is 1 where the
// picture is revealed; `k` is 1 at the front and 0 a band behind it,
// and the picture is read displaced toward the front by k squared. With
// a colour split the three channels are read at three offsets.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uImage;
uniform vec2 uResolution,uDirection;
uniform float uProgress,uSeed;
uniform vec3 uGround,uShade,uTint;
uniform vec4 uDial,uMode;
out vec4 o;
float h(vec2 p){p=fract(p*vec2(.1031,.1030)+uSeed);p+=dot(p,p.yx+33.33);return fract((p.x+p.y)*p.x);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
vec3 at(vec2 uv){return texture(uImage,clamp(uv,0.,1.)).rgb;}
void main(){
vec2 uv=gl_FragCoord.xy/uResolution;uv.y=1.-uv.y;
vec2 asp=vec2(uResolution.x/uResolution.y,1.);
vec2 dir=uDirection;
float s=dot(uv-.5,dir)+.5;
float band=uDial.x,rough=uDial.z;
float e=mix(-rough-.05,1.+band+rough,uProgress);
float nz=(n(uv*asp*uDial.w)-.5)*rough;
float m=smoothstep(e,e-.03,s+nz);
float k=smoothstep(e-band,e,s+nz);
vec2 d=dir*k*k*uDial.y;
float sp=uMode.x*k;
vec3 col=vec3(at(uv-d-dir*sp).r,at(uv-d).g,at(uv-d+dir*sp).b);
o=vec4(mix(uGround,col,m),1);}`;

/** CSS tokens read into uniforms, with what stands in for a missing one. Only the ground is used here. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uGround', '--ground', '#fff'],
  ['uShade', '--ink', '#000'],
  ['uTint', '--ground', '#fff'],
];

/** The lid starts as the page's ground, so the first frame hides the picture: it arrives. */
const FIRST = 'ground' as const;
void FIRST;

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

const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  gl.uniform4f(at('uDial'), clamp(o.band, 0.02, 0.6), clamp(o.stretch, 0, 0.5), clamp(o.rough, 0, 0.3), clamp(o.frequency, 1, 16));
  gl.uniform4f(at('uMode'), clamp(o.split, 0, 0.2), 0, 0, 0);
};

const tonesOf = (_o: Options): [shade: string, tint: string] => ['--ink', '--ground'];

/** The front's direction: the entering edge, or a fixed one. In UV with y down. */
export function directionOf(o: Options, entered: [number, number]): [number, number] {
  const fixed: [number, number][] = [
    [1, 0],
    [-1, 0],
    [0, 1],
    [0, -1],
  ];
  const choice = clamp(Math.round(o.direction), 0, 4);
  return choice === 0 ? entered : (fixed[choice - 1] ?? entered);
}
const directionFor = directionOf;

/** One triangle that covers the lid; the fragment shader does the rest. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
/** One draw of that triangle. */
const draws: Draws = (gl) => gl.drawArrays(gl.TRIANGLES, 0, 3);
/** A picture in view at mount is left plain: an arrival is for what comes in. */
const COVERS_IN_VIEW = false;

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Torn)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Sweep: {},
  Torn: { rough: 0.22, frequency: 9, band: 0.35 },
  Clean: { stretch: 0, rough: 0, band: 0.15 },
  Fringed: { split: 0.08, stretch: 0.3, duration: 650 },
};

// ---- HARNESS lid: begin -----------------------------------------------------
//
// Identical in every lid 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, stagger, maxLive, maxPixelRatio, rescueMs and once),
// DEFAULTS, Handle, VERTEX, FRAGMENT, TOKENS, FIRST, uploadDials, tonesOf,
// directionFor, draws (the draw call, given the context, the uniform
// locations and the options; most recipes draw one triangle) and
// COVERS_IN_VIEW (whether a picture in view at mount arrives, or is
// left plain as a gallery's are).
//
// The real `img` is never hidden and never moved. An effect is one
// opaque canvas laid over it for as long as the effect runs, drawn from
// a copy of the image made off the main thread at the canvas's own
// pixel size. Taking the canvas off is the rest state and every failure
// path. Contexts are pooled, because they are the scarce thing.

/** The draw call, given the context, the uniform locations and the options. */
type Draws = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, options: Options) => void;

const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const CEILING = 6;
const IDLE_MS = 4000;

/** A pooled canvas with its context and the recipe's program. */
export interface Entry {
  canvas: HTMLCanvasElement;
  gl: WebGL2RenderingContext;
  program: WebGLProgram;
  locations: Map<string, WebGLUniformLocation | null>;
  texture: WebGLTexture;
  linkedAt: number;
  busy: boolean;
  idleTimer: number;
}
/** One image, from mount to its lift. */
export interface Lid {
  img: HTMLImageElement;
  parent: HTMLElement;
  bitmap: ImageBitmap | null;
  preparing: string | null;
  near: boolean;
  done: boolean;
  entry: Entry | null;
  clock: Animation | null;
  rescue: Animation | null;
  frame: number;
  direction: [number, number];
  /** Covers tried while a fresh entry's program was still linking. */
  tries: number;
  /** Has entered the viewport and is waiting for a lid. */
  entered: boolean;
  /** Its bitmap came after it entered, so the lid goes on faded, not cut. */
  late: boolean;
}

const pool: Entry[] = [];
let webglOff = false;
const queue: (() => void)[] = [];
let draining = 0;

/** The lid's backing size: the box in device pixels, capped by a ratio snapped to a whole fraction of the device's. */
export function lidSize(cssWidth: number, cssHeight: number, pixelRatio: number, maxPixelRatio: number): { width: number; height: number; ratio: number } {
  const dpr = Math.max(0.5, pixelRatio || 1);
  let n = 1;
  while (dpr / n > Math.max(0.25, maxPixelRatio) + 1e-9) n += 1;
  const ratio = dpr / n;
  return { width: Math.max(1, Math.round(cssWidth * ratio)), height: Math.max(1, Math.round(cssHeight * ratio)), ratio };
}

/** The part of the source that object-fit: cover shows in a box of this shape, 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 };
}

/**
 * Whether a group of this size can all have lids: no more than the cap
 * may be live, and what is not idle has to be made under the cap. Idle
 * entries left by an earlier mount with a larger cap do not raise it.
 */
export function groupCovered(size: number, idle: number, live: number, maxLive: number): boolean {
  const cap = Math.min(CEILING, Math.max(1, Math.round(maxLive)));
  const slots = Math.max(0, cap - live);
  const creatable = Math.max(0, cap - idle - live);
  return size <= Math.min(slots, idle + creatable);
}

/** The edge an image entered by: from the top when it rose from below, from the bottom when it came down. In UV with y down. */
export function enterDirection(box: { top: number; bottom: number }, root: { top: number; bottom: number }): [number, number] {
  if (box.top < root.top && box.bottom <= root.bottom) return [0, -1];
  return [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])];
}

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;
  }
  const out = merged as unknown as Options;
  out.maxLive = Math.min(CEILING, Math.max(1, Math.round(out.maxLive)));
  return out;
}

/** One lid covered per frame, page-wide: covering costs 4 to 35 ms of main thread. */
const enqueue = (job: () => void): void => {
  queue.push(job);
  if (draining === 0) {
    const drain = (): void => {
      const next = queue.shift();
      if (next) next();
      draining = queue.length > 0 ? requestAnimationFrame(drain) : 0;
    };
    draining = requestAnimationFrame(drain);
  }
};

/** A new pool entry: its program is linked now and asked about later. */
const make = (): Entry | null => {
  if (webglOff) return null;
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: 'low-power', failIfMajorPerformanceCaveat: true });
  if (!gl) {
    webglOff = true;
    return null;
  }
  const 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 null;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    gl.attachShader(program, shader);
    gl.deleteShader(shader);
  }
  gl.linkProgram(program);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  for (const [key, value] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]] as const) {
    gl.texParameteri(gl.TEXTURE_2D, key, value);
  }
  canvas.setAttribute('aria-hidden', 'true');
  const entry: Entry = { canvas, gl, program, locations: new Map(), texture, linkedAt: performance.now(), busy: false, idleTimer: 0 };
  pool.push(entry);
  return entry;
};
/** An idle entry, or a new one. */
const warm = (): Entry | null => pool.find((entry) => !entry.busy) ?? make();
const release = (entry: Entry): void => {
  entry.busy = false;
  // A lid comes off through a fade that fills forwards. Left on the
  // canvas, it held the canvas at opacity 0 for the next picture the
  // pool lent it to: a lid on, drawing, that nobody could see. Seen on
  // the owner's bench with the recipe's own state on screen.
  for (const animation of entry.canvas.getAnimations()) animation.cancel();
  entry.canvas.remove();
  clearTimeout(entry.idleTimer);
  entry.idleTimer = window.setTimeout(() => {
    if (entry.busy) return;
    const at = pool.indexOf(entry);
    if (at !== -1) pool.splice(at, 1);
    entry.gl.deleteProgram(entry.program);
    entry.gl.deleteTexture(entry.texture);
    entry.gl.getExtension('WEBGL_lose_context')?.loseContext();
  }, IDLE_MS);
};
const drop = (entry: Entry): void => {
  entry.busy = false;
  entry.canvas.remove();
  clearTimeout(entry.idleTimer);
  const at = pool.indexOf(entry);
  if (at !== -1) pool.splice(at, 1);
};

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 createImageBitmap === 'undefined' ||
    typeof IntersectionObserver === 'undefined' ||
    typeof ResizeObserver === 'undefined'
  ) {
    return NOOP;
  }
  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 images = element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img'));
  const lids: Lid[] = [];
  for (const img of images) {
    const parent = img.parentElement;
    if (!parent || ['static', ''].includes(getComputedStyle(parent).position)) continue;
    lids.push({ img, parent, bitmap: null, preparing: null, near: false, done: false, entry: null, clock: null, rescue: null, frame: 0, direction: [0, 1], tries: 0, entered: false, late: false });
  }
  const byImage = new Map(lids.map((lid) => [lid.img, lid]));
  let done = false;

  const size = (lid: Lid): { width: number; height: number; ratio: number } =>
    lidSize(lid.img.clientWidth, lid.img.clientHeight, devicePixelRatio || 1, options.maxPixelRatio);

  /** The bitmap for a lid: decoded, cropped to what object-fit shows, resized to the lid, off the main thread. */
  const prepare = (lid: Lid): void => {
    const img = lid.img;
    if (!img.complete || img.naturalWidth === 0) return;
    const { width, height } = size(lid);
    const key = `${img.currentSrc}|${width}x${height}`;
    if (lid.preparing === key) return;
    if (lid.preparing === null && lid.bitmap && lid.bitmap.width === width && lid.bitmap.height === height) return;
    lid.preparing = key;
    const fit = getComputedStyle(img).objectFit;
    const crop = fit === 'cover' ? coverCrop(img.naturalWidth, img.naturalHeight, img.clientWidth, img.clientHeight) : { sx: 0, sy: 0, sw: img.naturalWidth, sh: img.naturalHeight };
    createImageBitmap(img, crop.sx, crop.sy, crop.sw, crop.sh, { resizeWidth: width, resizeHeight: height, resizeQuality: 'high' }).then(
      (bitmap) => {
        if (lid.preparing !== key || done) return bitmap.close();
        lid.bitmap?.close();
        lid.bitmap = bitmap;
        lid.preparing = null;
        // Enough entries for the images that are near, up to the cap,
        // so that none has to be made at the moment of covering.
        const wanted = Math.min(options.maxLive, lids.filter((item) => item.near && !item.done).length);
        while (pool.length < wanted && make()) {
          // one entry a call, until the pool holds enough
        }
        // An image that entered before its bitmap was ready has been
        // waiting: it is covered now, late, rather than left the one
        // plain tile among treated neighbours. On a slower machine or a
        // 2x screen some of a row's bitmaps come after a fast scroll in.
        if (lid.entered && !lid.entry && !lid.done) {
          lid.late = true;
          regroup(lid);
        }
      },
      () => {
        lid.preparing = null;
      },
    );
  };
  const forget = (lid: Lid): void => {
    lid.bitmap?.close();
    lid.bitmap = null;
    lid.preparing = null;
  };

  const tokens = (canvas: HTMLCanvasElement, gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null): void => {
    const [shade, tint] = tonesOf(options);
    const probe = canvas.style;
    for (const [uniform, property, fallback] of TOKENS) {
      const read = uniform === 'uShade' ? shade : uniform === 'uTint' ? tint : property;
      // 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(${read}, ${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 = '';
  };

  /**
   * Ends one lid. At the end of an effect it fades off on the compositor
   * and its entry goes back to the pool; at a stop or a destroy it goes
   * back at once, so the next mount finds it warm and no context is left
   * behind; a lost context is dropped from the pool.
   */
  const off = (lid: Lid, how: 'fade' | 'now' | 'drop' = 'fade'): void => {
    cancelAnimationFrame(lid.frame);
    lid.frame = 0;
    lid.clock?.cancel();
    lid.clock = null;
    lid.rescue?.cancel();
    lid.rescue = null;
    const entry = lid.entry;
    lid.entry = null;
    if (!entry) return;
    if (how === 'drop') return drop(entry);
    if (how === 'now') return release(entry);
    entry.canvas.dataset.state = 'off';
    const fade = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: 'forwards' });
    fade.onfinish = () => release(entry);
  };

  const draw = (lid: Lid): void => {
    lid.frame = 0;
    const entry = lid.entry;
    if (!entry || !lid.clock || done) return;
    const gl = entry.gl;
    const at = (name: string): WebGLUniformLocation | null => {
      if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
      return entry.locations.get(name) ?? null;
    };
    // During a stagger's delay the progress is null: not yet begun, not done.
    const progress = lid.clock.effect?.getComputedTiming().progress ?? 0;
    gl.uniform1f(at('uProgress'), progress);
    uploadDials(gl, at, options);
    draws(gl, at, options);
    if (lid.rescue) lid.rescue.currentTime = 0;
    if (progress >= 1) {
      lid.done = options.once;
      off(lid);
    } else {
      lid.frame = requestAnimationFrame(() => draw(lid));
    }
  };

  /** Puts the lid on: upload, uniforms, first frame, insert, clock, rescue. Anything thrown leaves the image plain. */
  const cover = (lid: Lid, delay: number): void => {
    if (done || lid.done || lid.entry || !lid.bitmap) return;
    const entry = warm();
    if (!entry) return;
    const gl = entry.gl;
    // Asked only now, at least two frames after linking, and never
    // blocking in practice: the entry was warmed when the image was near.
    // One made just now is given a few frames before it is asked.
    if (performance.now() - entry.linkedAt < 34) {
      lid.tries += 1;
      if (lid.tries < 12) enqueue(() => cover(lid, delay));
      return;
    }
    if (!gl.getProgramParameter(entry.program, gl.LINK_STATUS)) return;
    try {
      entry.busy = true;
      clearTimeout(entry.idleTimer);
      for (const animation of entry.canvas.getAnimations()) animation.cancel();
      lid.entry = entry;
      const { width, height } = size(lid);
      entry.canvas.width = width;
      entry.canvas.height = height;
      entry.canvas.style.cssText = `position:absolute;left:${lid.img.offsetLeft}px;top:${lid.img.offsetTop}px;width:${lid.img.offsetWidth}px;height:${lid.img.offsetHeight}px;pointer-events:none;border-radius:${getComputedStyle(lid.img).borderRadius};visibility:hidden;image-rendering:pixelated`;
      entry.canvas.dataset.state = 'running';
      lid.img.after(entry.canvas);
      gl.viewport(0, 0, width, height);
      gl.useProgram(entry.program);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, entry.texture);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, lid.bitmap);
      const at = (name: string): WebGLUniformLocation | null => {
        if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
        return entry.locations.get(name) ?? null;
      };
      gl.uniform1i(at('uImage'), 0);
      gl.uniform2f(at('uResolution'), width, height);
      gl.uniform1f(at('uSeed'), Math.random());
      const direction = directionFor(options, lid.direction);
      gl.uniform2f(at('uDirection'), direction[0], direction[1]);
      // In the document, hidden, so the tokens have a computed value.
      tokens(entry.canvas, gl, at);
      gl.uniform1f(at('uProgress'), 0);
      uploadDials(gl, at, options);
      draws(gl, at, options);
      entry.canvas.style.visibility = '';
      // A lid that comes late goes over a picture the reader has seen: faded on, not cut.
      if (lid.late) entry.canvas.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 120 });
      const easing = getComputedStyle(lid.img).getPropertyValue('--load-ease').trim() || 'cubic-bezier(0.4, 0, 0.2, 1)';
      const timing = { duration: Math.max(1, options.duration), delay, fill: 'forwards' as const };
      try {
        lid.clock = entry.canvas.animate(null, { ...timing, easing });
      } catch {
        lid.clock = entry.canvas.animate(null, timing);
      }
      lid.rescue = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { delay: Math.max(500, options.rescueMs), duration: 200, fill: 'forwards' });
      lid.rescue.onfinish = () => off(lid);
      lid.frame = requestAnimationFrame(() => draw(lid));
    } catch {
      // A cross-origin image throws on upload: this image is plain for good.
      lid.done = true;
      off(lid, 'now');
    }
  };

  // Images that enter in the same frame are grouped by row, and a row
  // is all or none: the first row takes the lids there are, a later row
  // that cannot be covered whole arrives plain. A row half treated was
  // seen in the research and is worse than either.
  // An image whose bitmap is not ready when it enters stays entered, and
  // is grouped again the moment the bitmap comes.
  // Entering does not wait on "near": the observers report in no
  // promised order, and an iframe drops the margin, so a row jumped to
  // was entered first, turned away, and never asked again.
  let group: Lid[] = [];
  let grouping = 0;
  const arrive = (lid: Lid, box: DOMRectReadOnly, root: DOMRectReadOnly | null): void => {
    if (lid.done || lid.entry) return;
    lid.direction = enterDirection(box, root ?? { top: 0, bottom: innerHeight });
    lid.entered = true;
    lid.near = true;
    prepare(lid);
    regroup(lid);
  };
  // Once each: handed in again when its bitmap came, a row of three
  // counted as six, over the cap, and was left plain for good.
  const regroup = (lid: Lid): void => {
    if (!group.includes(lid)) group.push(lid);
    if (grouping) return;
    grouping = requestAnimationFrame(() => {
      grouping = 0;
      const ready = group.filter((item) => item.bitmap && !item.done && !item.entry);
      group = [];
      const rows = new Map<number, Lid[]>();
      for (const item of ready) {
        const top = Math.round(item.img.getBoundingClientRect().top / 4);
        rows.set(top, [...(rows.get(top) ?? []), item]);
      }
      let idle = pool.filter((entry) => !entry.busy).length;
      let live = pool.filter((entry) => entry.busy).length;
      let index = 0;
      for (const top of [...rows.keys()].sort((a, b) => a - b)) {
        const row = rows.get(top) ?? [];
        if (!groupCovered(row.length, idle, live, options.maxLive)) {
          for (const item of row) item.done = options.once;
          continue;
        }
        for (const item of row) {
          const at = index;
          index += 1;
          enqueue(() => cover(item, at * Math.max(0, options.stagger)));
        }
        const fromIdle = Math.min(idle, row.length);
        idle -= fromIdle;
        live += row.length;
      }
    });
  };

  const nearness = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const lid = byImage.get(entry.target as HTMLImageElement);
        if (!lid) continue;
        lid.near = entry.isIntersecting;
        if (lid.near) prepare(lid);
        else forget(lid);
      }
    },
    { rootMargin: '60%' },
  );
  const entering = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const lid = byImage.get(entry.target as HTMLImageElement);
        if (!lid) continue;
        if (entry.intersectionRatio >= 0.12) arrive(lid, entry.boundingClientRect, entry.rootBounds);
      }
    },
    { threshold: [0.12] },
  );
  const sizes = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const lid = byImage.get(entry.target as HTMLImageElement);
      if (!lid) continue;
      // A resized image is a different lid: stop any running one, remake the bitmap.
      if (lid.entry) off(lid);
      if (lid.near) prepare(lid);
    }
  });
  const onLoad = (event: Event): void => {
    const lid = byImage.get(event.target as HTMLImageElement);
    if (lid?.near) prepare(lid);
  };
  const stopAll = (): void => {
    for (const lid of lids) if (lid.entry) off(lid, 'now');
  };
  const onVisibility = (): void => {
    if (document.visibilityState !== 'visible') stopAll();
  };
  const onLost = (event: Event): void => {
    const canvas = event.target as HTMLCanvasElement;
    const lid = lids.find((item) => item.entry?.canvas === canvas);
    if (lid) off(lid, 'drop');
    const entry = pool.find((item) => item.canvas === canvas);
    if (entry) drop(entry);
  };

  // In view at mount is left plain unless the recipe says otherwise: an
  // arrival is for what comes in, but a hero is on screen at load.
  for (const lid of lids) {
    const box = lid.img.getBoundingClientRect();
    if (!COVERS_IN_VIEW && box.bottom > 0 && box.top < innerHeight && box.height > 0) lid.done = options.once;
    nearness.observe(lid.img);
    entering.observe(lid.img);
    sizes.observe(lid.img);
    lid.img.addEventListener('load', onLoad);
  }
  addEventListener('beforeprint', stopAll);
  document.addEventListener('visibilitychange', onVisibility);
  document.addEventListener('webglcontextlost', onLost, true);

  const handle: Handle = {
    update(next) {
      options = mergeOptions(options, next);
    },
    destroy() {
      done = true;
      nearness.disconnect();
      entering.disconnect();
      sizes.disconnect();
      cancelAnimationFrame(grouping);
      grouping = 0;
      removeEventListener('beforeprint', stopAll);
      document.removeEventListener('visibilitychange', onVisibility);
      document.removeEventListener('webglcontextlost', onLost, true);
      for (const lid of lids) {
        lid.img.removeEventListener('load', onLoad);
        if (lid.entry) off(lid, 'now');
        forget(lid);
      }
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  return handle;
}

// ---- HARNESS lid: end -------------------------------------------------------