atelier

Catalog / Recipe

Ink sharpen

The heading starts swollen and inky, its letters fused into one rounded shape with a rough edge, and sharpens back to itself; the canvas then fades onto the live word. Reversed, ink swells over the word and fades away, leaving it.

Family
topology
Status
passed
Used for
HeroPage load
Size
5,852 of 6,200 bytes, gzipped
Frame
Skin
Version
direction
tint
trigger
mount(element)

The distance field of word-contour read the other way. Where the rings live outside a gap, this fills everything within a distance of the glyphs that starts at a swell and shrinks to zero, roughened at the edge by noise, so the heading begins as one inky shape and sharpens into the word. The fill is the text’s own colour over the live text, and at the end the canvas fades onto it.

Use

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

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

The mark is the first [data-mask] inside the element, else its first h1 or h2: one line of display type, 32 px or more, which stays as it is, real text under a transparent canvas. A picture is never a mark here: its colour is not one.

The field

The word is drawn as the page renders it (whitespace collapsed, the transform applied in the page’s language, one line measured through a Range, the half-leading baseline, letter spacing and stretch) after the fonts settle or 1.5 s, in the element’s padding box at CSS resolution, no wider or taller than 1,200 px. Every pixel’s distance from the nearest inked edge is computed exactly on the main thread, in three tasks, with the nearest edge pixel’s coverage correcting it. The field is not blurred: the fill ends on the glyph’s own edge, where a blurred field would pull the ink inside it. The text’s computed colour is read and set as a uniform with the field’s scale and the heading’s size.

The shade

One pass. The edge stands off the glyph by swell at the start and shrinks to zero as the cube of the time left, roughened by seeded value noise of rough height and grain size, softened by blur at the start and half a pixel at the end. Everything inside the edge is filled in the text’s colour, over the text, which hides the pixel by which the canvas word and the live word differ: a fill outside the glyph only showed a light seam down every stem. Over the last 15% the fill fades onto the live word. Bleed swells as the cube of progress and fades over its last 40%.

Options

Option Default What it does
swell 0.2 How far the ink stands off the glyph at the start, em, 0.05 to 0.4
rough 0.125 Noise on the edge, em, up to 0.2
grain 40 Noise feature size, CSS px, 20 to 120; in px where swell and rough are in em, so on a 300 px letter it is fine fuzz and on a 44 px heading lumps
blur 3 Edge softness at the start, CSS px, up to 8
direction 0 0 sharpen; 1 bleed, ink swelling over the word and fading away. The live word stays throughout: Bleed is a flourish, not an exit
tint 0 0 the text’s own colour, alpha included; 1 the ink token, opaque, for lettering set as a tint of the ink
trigger 0 0 on load, 1 on entering view. A bleed on entering view leaves as it arrives: a mistake
duration, maxPixelRatio, maxPixels, arrivalOnly 1100, 2, 2,500,000, true As every one-shot recipe

Versions

Version What it is
Sharpen The defaults
Bleed The reverse: ink swells over the word and fades away, and the word stays
Ink The ink token, opaque, whatever the lettering’s own colour
Rough A ragged, inky edge
Soft A smooth, smoky start
Intro Nothing until the element enters view

Where it must not go

The letters fuse for the first half of the run, so never on a word the reader must read at first sight unless it is short, and never on a section heading mid-read. In Turkish, ı followed by n fused into m at a third of the run in the research, and the dots of i and İ and the breve of ğ swell into blobs before the letters do: keep it under about 1.2 s and look at the word. A hero, a page’s arrival.

What it does when things are missing

Situation What the reader sees
No WebGL2, a software renderer The heading, plain
No text mark, an empty or two-line mark, type under 32 px, a font the canvas cannot parse, a font-stretch the canvas cannot draw, a page host, a picture as the mark, a mask with no ink The heading, plain; nothing attached
Fonts not settled in 1.5 s Ink in the fallback face’s shape; the swap moves the word under it
A text colour the recipe cannot read: oklch() and the other syntaxes are read through the browser’s own conversion, so this is a browser without color-mix (Chrome before 111, Safari before 16.2, Firefox before 113) with such a colour The ink in black; or, with the plain syntaxes, the text’s own colour as always
A heading painted by background-clip: text with a transparent colour, or by -webkit-text-fill-color A fill with no alpha, or in the wrong colour: the recipe reads color only. Use tint 1
A host wider or taller than 1,200 px The mask at a lower resolution, one mask pixel up to 1.6 CSS px at 1,920 wide; the last frames a softer copy over the crisp word, within about a pixel
Reduced motion, forced colours; no IntersectionObserver with trigger 1 The heading, plain; or the run on load
The upload fails Nothing attached
The run ends The heading, plain; nothing is kept

Learned while building it

  • The band fixture’s display lettering is the ink token at 14% alpha, not a grey, and a colour read that dropped the alpha filled the word in black: a black blob over a light word, dropping to the word at the end. The colour is read through the harness’s colour-mix probe and parsed with its alpha, and the fill carries it. On translucent lettering the fill then doubles the tint over the glyphs and reads as a halo around a darker word for the run; on opaque lettering, which the recipe is for, it is the word in ink.
  • The review found the colour parser refusing a colour outside sRGB: a colour-mix result is not gamut-mapped, so a vivid oklch() accent comes back with negative channels, which a parser of digits and dots dropped, and the fill fell back to black. The parser now takes signs and exponents and clamps, as the harness’s own does. The checks now read the halo’s colour against the text’s colour over the ground, which a black or an opaque fill misses by hundreds of levels; the branch’s first bug would have passed every check before.
  • The transform yields every 120,000 pixels of work inside each half as well as between them, so a hero-sized host (1,200 by 580, 2.6 times the band) runs in many tasks of a few tens of milliseconds; before, its halves would have been over 100 ms each at page arrival.
  • tint 1 fills in the ink token, opaque: the look the research chose, for lettering set as a tint of the ink, where the text’s own colour gives a halo. On the catalog’s band fixture that is the Ink version.
  • Bleed cannot end empty: the harness never hides content, and the live word stays. The ink swells over it and fades; what the reader sees is the word, ink over it, and the word. The first docs promised an exit.
  • 5,753 bytes gzipped after the review’s fixes by the reviewer’s measure, 5,616 before: word-contour’s helpers without the picture ones and the blur, a shorter shader.
ink-sharpen.ts832 lines · 5,852 bytes gzipped · no imports
/**
 * ink-sharpen
 *
 * The heading starts swollen and inky, its letters fused into one
 * rounded shape with a rough edge, and sharpens back to itself; at the
 * end the canvas fades onto the live word. Reversed, ink swells over
 * the word and fades away, and the word stays: a flourish, not an exit.
 * The distance field of `word-contour`, read the other
 * way: everything within a shrinking distance of the glyphs is filled,
 * in the text's own colour, over the live text.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './ink-sharpen';
 *     mount(document.querySelector('.hero'));
 *
 * The mark is the first [data-mask] inside the element, else its first
 * heading: one line of display type, 32 px or more. It runs once, for
 * about a second, on arrival or when the element enters view. The
 * letters fuse for the first half of the run: a hero or a page's
 * arrival, never a word that must be read at first sight.
 */

export interface Options {
  /** Length of the run in ms. */
  duration: number;
  /** Ceiling on canvas pixels per CSS pixel. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Run only on an arrival from outside the site, not on a reload or a history move. */
  arrivalOnly: boolean;
  /** How far the ink stands off the glyph at the start, in em of the heading, 0.05 to 0.4. */
  swell: number;
  /** Noise on the edge, em, 0 to 0.2. */
  rough: number;
  /** Noise feature size, CSS px, 20 to 120. */
  grain: number;
  /** Edge softness at the start, CSS px, 0 to 8. */
  blur: number;
  /** 0 sharpen, from ink to the word; 1 bleed, the reverse: the ink swells over the word and fades, and the word stays. */
  direction: number;
  /** 0 the text's own colour, alpha included; 1 the ink token, opaque, for lettering set as a tint of the ink. */
  tint: number;
  /** 0 on load, 1 when the element enters view. A bleed on entering view is a heading that leaves as it arrives: a mistake. */
  trigger: number;
}

export const DEFAULTS: Options = {
  duration: 1100,
  maxPixelRatio: 2,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  swell: 0.2,
  rough: 0.125,
  grain: 40,
  blur: 3,
  direction: 0,
  tint: 0,
  trigger: 0,
};

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

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

const VERTEX = `#version 300 es
void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);gl_Position=vec4(p*2.-1.,0,1);}`;

// The field is distance from the glyphs in mask px, 0 on and inside
// them; divided by the mask's scale it is CSS px. The fill is everything
// within an edge that starts at the swell and shrinks to zero as the
// cube of the time left, roughened by seeded value noise and softened
// at the start; at the end the edge is the glyph itself and the fill
// fades onto the live word over the last 15%. The fill is the text's
// own colour over the text: a fill outside the glyph only left a light
// seam down every stem, since the canvas word sits within about a pixel
// of the live one. The text's alpha is kept, so lettering set as a
// tint of the ink is filled at that tint; over the live text it doubles
// the tint for the run, which the fade at the end takes back. A bleed
// swells as the cube of progress and fades over its last 40%.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uField;
uniform vec2 uResolution;
uniform float uPixelRatio,uProgress,uTime,uSeed,uScale,uFont;
uniform vec4 uInk,uMove,uText;
uniform vec3 uInk0;
out vec4 o;
uint hh(uint x){x^=x>>16u;x*=0x7feb352du;x^=x>>15u;x*=0x846ca68bu;x^=x>>16u;return x;}
float h2(ivec2 p){return float(hh(uint(p.x)*1973u+hh(uint(p.y)+uint(uSeed*65535.))))/4294967295.;}
float vn(vec2 p){ivec2 i=ivec2(floor(p));vec2 f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h2(i),h2(i+ivec2(1,0)),f.x),mix(h2(i+ivec2(0,1)),h2(i+ivec2(1,1)),f.x),f.y);}
void main(){
vec2 uv=gl_FragCoord.xy/uResolution;uv.y=1.-uv.y;
vec2 c=gl_FragCoord.xy/uPixelRatio;
float d=texture(uField,uv).r/uScale;
float p=uProgress,s=uMove.x>.5?p*p*p:(1.-p)*(1.-p)*(1.-p);
float n=(vn(c/uInk.z)+.5*vn(c/uInk.z*2.03)-.75)*1.33;
float edge=s*uInk.x*uFont+n*2.*s*uInk.y*uFont;
float soft=.5+s*uInk.w;
float a=1.-smoothstep(edge-soft,edge+soft,d);
a*=uMove.x>.5?1.-smoothstep(.6,1.,p):1.-smoothstep(.85,1.,p);
vec4 t=uMove.y>.5?vec4(uInk0,1.):uText;o=vec4(t.rgb*a,a)*t.a;
}`;

/** The ink token, used only with tint 1; otherwise the ink is the text's own computed colour, set in prepare. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [['uInk0', '--ink', '#000']];

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 {
  swell: number;
  rough: number;
  grain: number;
  blur: number;
  direction: number;
  tint: number;
  trigger: number;
}

/** Every dial inside the range the shader survives. */
export function dials(o: Options): Dials {
  return {
    swell: clamp(o.swell, 0.05, 0.4),
    rough: clamp(o.rough, 0, 0.2),
    grain: clamp(o.grain, 20, 120),
    blur: clamp(o.blur, 0, 8),
    direction: whole(o.direction, 0, 1),
    tint: whole(o.tint, 0, 1),
    trigger: whole(o.trigger, 0, 1),
  };
}

/**
 * A computed colour as four channels in 0..1, the alpha kept: rgb(),
 * rgba(), or color(srgb ...) with or without a slash alpha; null for
 * anything else. The alpha matters: display lettering set at a tint of
 * the ink is ink at a low alpha, and the fill must be the same.
 */
export function textColour(value: string): [number, number, number, number] | null {
  // A channel may be negative or in e-notation: a colour outside sRGB
  // mixed in sRGB is not gamut-mapped, and comes back so. Clamped.
  const ch = (n: number): number => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0));
  const rgb = /^rgba?\(\s*([-\d.e+]+)[,\s]+([-\d.e+]+)[,\s]+([-\d.e+]+)(?:\s*[,/]\s*([-\d.e+]+%?))?/.exec(value);
  if (rgb) return [ch(Number(rgb[1]) / 255), ch(Number(rgb[2]) / 255), ch(Number(rgb[3]) / 255), alphaOf(rgb[4])];
  const srgb = /^color\(srgb\s+([-\d.e+]+)\s+([-\d.e+]+)\s+([-\d.e+]+)(?:\s*\/\s*([-\d.e+]+%?))?/.exec(value);
  if (srgb) return [ch(Number(srgb[1])), ch(Number(srgb[2])), ch(Number(srgb[3])), alphaOf(srgb[4])];
  return null;
}

/** An alpha token, a number or a percentage, 1 when absent. */
const alphaOf = (token: string | undefined): number => {
  if (token === undefined) return 1;
  const n = token.endsWith('%') ? Number(token.slice(0, -1)) / 100 : Number(token);
  return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1;
};

const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  const d = dials(o);
  gl.uniform4f(at('uInk'), d.swell, d.rough, d.grain, d.blur);
  gl.uniform4f(at('uMove'), d.direction, d.tint, 0, 0);
};

/** Nothing is kept: the canvas fades onto the live word and leaves. */
const keepsStill = (_options: Options): boolean => false;

const fadeInMs = (_options: Options): number => 0;

const layerUnder = (_options: Options): boolean => false;

const canvasStyle = (_options: Options): string => '';

export const geometry = (gl: WebGL2RenderingContext, _o: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];

const DEPTH = false;

const startsOnView = (o: Options): boolean => dials(o).trigger === 1;

/** The first [data-mask] in the element, else its first heading; never a picture, whose colour is not one. */
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))]);
}

/** The element's padding box in viewport coordinates: where an absolutely placed canvas at inset 0 sits. */
export function paddingBox(element: HTMLElement): { left: number; top: number; width: number; height: number } {
  const rect = element.getBoundingClientRect();
  return { left: rect.left + element.clientLeft, top: rect.top + element.clientTop, width: element.clientWidth, height: element.clientHeight };
}

/** The canvas keyword for a computed font-stretch percentage, 'normal' for 100%, or null for a width no keyword names. */
export function stretchKeyword(value: string): string | null {
  const pct = value.endsWith('%') ? Number(value.slice(0, -1)) : value === 'normal' ? 100 : NaN;
  const table: Record<number, string> = { 50: 'ultra-condensed', 62.5: 'extra-condensed', 75: 'condensed', 87.5: 'semi-condensed', 100: 'normal', 112.5: 'semi-expanded', 125: 'expanded', 150: 'extra-expanded', 200: 'ultra-expanded' };
  return table[pct] ?? null;
}

/** One row or column of the Felzenszwalb-Huttenlocher transform: the lower envelope of parabolas, recording the nearest feature. */
function pass(f: Float32Array, off: number, stride: number, n: number, d: Float32Array, v: Uint16Array, z: Float32Array, arg: Uint16Array): void {
  let k = 0;
  v[0] = 0;
  z[0] = -1e20;
  z[1] = 1e20;
  for (let q = 1; q < n; q += 1) {
    const fq = (f[off + q * stride] as number) + q * q;
    let s = 0;
    for (;;) {
      const r = v[k] as number;
      s = (fq - (f[off + r * stride] as number) - r * r) / (2 * (q - r));
      if (s <= (z[k] as number) && k > 0) k -= 1;
      else break;
    }
    k += 1;
    v[k] = q;
    z[k] = s;
    z[k + 1] = 1e20;
  }
  k = 0;
  for (let q = 0; q < n; q += 1) {
    while ((z[k + 1] as number) < q) k += 1;
    const r = v[k] as number;
    d[q] = (q - r) * (q - r) + (f[off + r * stride] as number);
    arg[q] = r;
  }
  for (let q = 0; q < n; q += 1) f[off + q * stride] = d[q] as number;
}

const yieldTask = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
/** Pixels of transform work between yields: about 25 ms on an integrated laptop. */
const TASK_PIXELS = 120_000;

/**
 * Distance outside the ink in mask px, from the alpha of RGBA pixels:
 * every pixel with any ink is a feature, the transform is exact, and
 * each distance is corrected by the coverage of the nearest feature,
 * `sqrt(f) + 0.5 - a`, so an antialiased edge is where it is and not
 * where its pixel centre is. Columns, a yield, then rows: two tasks of
 * half the work each instead of one long one. Throws when nothing is
 * inked, since a field of infinities would draw nothing and say so to
 * nobody.
 */
export async function outside(alpha: Uint8ClampedArray, w: number, h: number): Promise<Float32Array> {
  const n = Math.max(w, h);
  const f = new Float32Array(w * h);
  const iy = new Uint16Array(w * h);
  let inked = 0;
  for (let i = 0; i < w * h; i += 1) {
    const a = alpha[i * 4 + 3] as number;
    if (a > 0) inked += 1;
    f[i] = a > 0 ? 0 : 1e10;
  }
  if (inked === 0) throw new Error('nothing inked');
  const d = new Float32Array(n), v = new Uint16Array(n), z = new Float32Array(n + 1), arg = new Uint16Array(n);
  // A yield every so many pixels of work: a hero-sized mask is then many
  // tasks of a few tens of milliseconds, not two of over a hundred.
  const every = Math.max(1, Math.floor(TASK_PIXELS / Math.max(w, h)));
  for (let x = 0; x < w; x += 1) {
    if (x > 0 && x % every === 0) await yieldTask();
    pass(f, x, w, h, d, v, z, arg);
    for (let y = 0; y < h; y += 1) iy[y * w + x] = arg[y] as number;
  }
  await yieldTask();
  for (let y = 0; y < h; y += 1) {
    if (y > 0 && y % every === 0) await yieldTask();
    pass(f, y * w, 1, w, d, v, z, arg);
    for (let x = 0; x < w; x += 1) {
      const i = y * w + x;
      const qx = arg[x] as number;
      const cover = (alpha[((iy[y * w + qx] as number) * w + qx) * 4 + 3] as number) / 255;
      f[i] = (alpha[i * 4 + 3] as number) === 255 ? 0 : Math.max(0, Math.sqrt(f[i] as number) + 0.5 - cover);
    }
  }
  return f;
}

/** The mask is drawn no wider or taller than this, in CSS px: the read-back and the transform are 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;
/** The fill fuses type smaller than this, in CSS px, past reading. */
const MIN_FONT = 32;

/**
 * Before the first frame: wait for the fonts, draw the word where the
 * page draws it, transform the coverage into an exact distance field in
 * two tasks after the read-back's own, upload it as R16F, and set the
 * field's scale, the heading's size and the text's colour as uniforms.
 * Throws for anything it cannot draw rightly, and the harness then
 * attaches nothing: no text mark, an empty or two-line text, type under
 * 32 px, a font the canvas cannot parse, a stretch it cannot draw, a
 * page host, a mask with no ink, an upload the context refuses.
 */
const prepare: Prepare = async (gl, at, element, o) => {
  await fontsReady(FONT_WAIT);
  const mark = markOf(element);
  if (!mark) throw new Error('no mark');
  // A page host would mean a document-tall mask squeezed onto the viewport: refused.
  if (element === document.body || element === document.documentElement) throw new Error('page host');
  // The canvas covers the padding box (inset 0 of a positioned host), so the mask is drawn in it too.
  const box = paddingBox(element);
  if (!(box.width > 0 && box.height > 0)) throw new Error('no box');
  // Full resolution and exact: the fill ends on the glyph's own edge.
  const scale = Math.min(1, MASK_WIDTH / box.width, MASK_WIDTH / box.height);
  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);
  const style = getComputedStyle(mark);
  const lang = mark.closest<HTMLElement>('[lang]')?.lang || document.documentElement.lang || 'en';
  const text = maskText(mark.textContent ?? '', style.textTransform, lang);
  const measured = text ? measureMark(element, mark) : null;
  if (!measured) throw new Error('no one-line mark');
  const font = parseFloat(style.fontSize);
  if (!(font >= MIN_FONT)) throw new Error('type too small');
  const { rect } = measured;
  // 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;
  // The computed stretch is a percentage and the canvas takes a keyword;
  // a width no keyword names, or a canvas without fontStretch, is refused.
  const stretch = stretchKeyword(style.fontStretch);
  if (stretch === null) throw new Error('stretch');
  if (stretch !== 'normal') {
    if (!('fontStretch' in text2d)) throw new Error('stretch');
    text2d.fontStretch = stretch 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 alpha = ctx.getImageData(0, 0, width, height).data;
  // The read-back is its own task; the transform's two halves follow, each after a yield.
  await yieldTask();
  const field = await outside(alpha, width, height);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
  // Linear filtering on a half-float texture is core in WebGL2; on a
  // 32-bit float it is not, and an incomplete texture reads as zero.
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  // A one-channel row of odd width is not 4-byte aligned.
  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, width, height, 0, gl.RED, gl.FLOAT, field);
  if (gl.getError() !== gl.NO_ERROR) throw new Error('upload');
  gl.uniform1i(at('uField'), 0);
  // Set here, on the program, never in module state: two mounts on one page would share a variable.
  gl.uniform1f(at('uScale'), scale);
  gl.uniform1f(at('uFont'), font);
  // Any colour syntax in, one serialisation out: a probe in the host
  // whose colour is the mark's mixed with itself in sRGB makes the
  // browser do the conversion, as the harness reads its tokens; it is
  // appended and removed in one task, so no frame can paint it. Without
  // colour-mix (Chrome before 111, Safari before 16.2) the probe inherits
  // the host's colour, and the plain parse of the mark's own value is
  // tried first for that reason.
  const probe = document.createElement('i');
  probe.style.color = `color-mix(in srgb, ${style.color} 100%, #000 0%)`;
  element.append(probe);
  const mixed = getComputedStyle(probe).color;
  probe.remove();
  const colour = textColour(style.color) ?? textColour(mixed) ?? [0, 0, 0, 1];
  gl.uniform4f(at('uText'), colour[0], colour[1], colour[2], colour[3]);
  gl.enable(gl.BLEND);
  gl.blendFunc(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.Bleed)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Sharpen: {},
  Bleed: { direction: 1 },
  Rough: { rough: 0.2, grain: 25 },
  Soft: { rough: 0, blur: 8 },
  Ink: { tint: 1 },
  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 --------------------------------------------------