Shader Transition

beta

Transitions a frame between two states under a full-frame WebGL reveal — one raw-WebGL engine and sixteen presets (aperture, chroma, prism, SDF blobs and circles, noise, wipe, stripes, spiral…).

Draft

Quarterly report, ready to review.

Press Publish to run the transition.

Installation

Terminal
pnpm dlx @dowel-ui/cli add shader-transition

No npm packages are needed beyond what Dowel already requires.

Accessibility

Both states are real DOM; the shader only paints a decorative, aria-hidden cover over the frame. While a run is in progress the outgoing state is aria-hidden and inert, the incoming state is in the accessibility tree from the first frame, and focus inside the outgoing state moves to the incoming one. The root is aria-busy while running. Reduced motion (OS setting or --motion-scale 0) swaps instantly; without WebGL, or if the context is lost mid-run, the states cross-fade instead.

Props

ShaderTransition

PropTypeDefault
accent

Colour token of the glow on the moving front. Default "primary".

string"primary"
accentSecondary

Colour token the preset's sheen tints the cover with. Default: accent mixed into surface.

string
active

Two-state API: which state is shown. Changing it runs the transition.

booleanfalse
duration

Run length in ms at --motion-scale 1. Defaults to the preset's.

number
from

Two-state API: shown while active is false.

ReactNode
onRest

Called when a run settles, however it ran.

() => void
onStart

Called when a run starts.

() => void
onSwap

Called when the incoming state becomes visible (the midpoint, or at once without motion).

() => void
preset

The reveal pattern. Default "noise".

ShaderTransitionPresetName"noise"
surface

Colour token of the cover — the surface behind the content. Default "background".

string"background"
to

Two-state API: shown while active is true.

ReactNode
transitionKey

Keyed API: changing the key runs the transition to the new children.

string | number

Plus every attribute of <div> except children.

Quality

8/9 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedfails
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add shader-transition writes into your project, with imports rewritten to your own path alias.

ui/shader-transition.tsx
"use client";

// Ported from SmoothUI "Shader Reveal Transition" (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type ReactNode,
} from "react";

import { cn } from "@/lib/utils";

import {
  createShaderRenderer,
  easeRun,
  markWebGLUnavailable,
  motionScale,
  prefersReducedMotion,
  resolveRgb,
  supportsWebGL,
  type Rgb,
  type ShaderRenderer,
} from "./shader-transition-engine";
import {
  getShaderTransitionPreset,
  type ShaderTransitionPresetName,
} from "./shader-transition-presets";

/*
 * A frame-level transition between two states, both of them real DOM.
 *
 * State machine (data-state on the root): idle → running → done, and running
 * again whenever the shown state changes. While running:
 *
 * - shader mode: a canvas over the frame grows a cover in the surface colour
 *   over the outgoing state (data-phase="cover"), the states swap under the
 *   full cover at the midpoint (data-phase="reveal") and the cover recedes over
 *   the incoming state. One WebGL context per run, created when the run starts
 *   on a fresh canvas and released when it ends — an idle instance holds none.
 * - fade mode: no WebGL (or the context was lost mid-run) — a CSS cross-fade.
 * - reduced motion (OS setting or --motion-scale 0), or the frame off-screen:
 *   the states swap instantly.
 *
 * Throughout, the outgoing layer is aria-hidden and inert, so assistive
 * technology and the keyboard only ever meet the incoming state; if focus was
 * inside the outgoing layer it moves to the incoming one.
 */

export type ShaderTransitionStatus = "idle" | "running" | "done";

export interface ShaderTransitionProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
  /** The reveal pattern. Default "noise". */
  preset?: ShaderTransitionPresetName;
  /** Two-state API: shown while `active` is false. */
  from?: ReactNode;
  /** Two-state API: shown while `active` is true. */
  to?: ReactNode;
  /** Two-state API: which state is shown. Changing it runs the transition. */
  active?: boolean;
  /** Keyed API: changing the key runs the transition to the new children. */
  transitionKey?: string | number;
  children?: ReactNode;
  /** Run length in ms at --motion-scale 1. Defaults to the preset's. */
  duration?: number;
  /** Colour token of the cover — the surface behind the content. Default "background". */
  surface?: string;
  /** Colour token of the glow on the moving front. Default "primary". */
  accent?: string;
  /** Colour token the preset's sheen tints the cover with. Default: accent mixed into surface. */
  accentSecondary?: string;
  /** Called when a run starts. */
  onStart?: () => void;
  /** Called when the incoming state becomes visible (the midpoint, or at once without motion). */
  onSwap?: () => void;
  /** Called when a run settles, however it ran. */
  onRest?: () => void;
}

interface Slot {
  key: string;
  node: ReactNode;
}

interface MachineState {
  shown: Slot;
  leaving: Slot | null;
  status: ShaderTransitionStatus;
  phase: "cover" | "reveal";
  mode: "shader" | "fade";
  /** Increments per run; keys the run effect. */
  run: number;
}

const FADE_SHARE = 0.5;

const STYLES = `
@keyframes dowel-shader-transition-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dowel-shader-transition-out { from { opacity: 1; } to { opacity: 0; } }
[data-slot="shader-transition"][data-mode="shader"][data-phase="cover"] > [data-slot="shader-transition-layer"][data-state="entering"] {
  opacity: 0;
  pointer-events: none;
}
[data-slot="shader-transition"][data-phase="reveal"] > [data-slot="shader-transition-layer"][data-state="leaving"] {
  visibility: hidden;
}
[data-slot="shader-transition"][data-mode="fade"] > [data-slot="shader-transition-layer"][data-state="leaving"] {
  animation: dowel-shader-transition-out var(--shader-transition-fade) var(--ease-in-out-quint) both;
  pointer-events: none;
}
[data-slot="shader-transition"][data-mode="fade"] > [data-slot="shader-transition-layer"][data-state="entering"] {
  animation: dowel-shader-transition-in var(--shader-transition-fade) var(--ease-in-out-quint) both;
}
`;

function mix(a: Rgb, b: Rgb, t: number): Rgb {
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}

export function ShaderTransition({
  preset = "noise",
  from,
  to,
  active = false,
  transitionKey,
  children,
  duration,
  surface = "background",
  accent = "primary",
  accentSecondary,
  onStart,
  onSwap,
  onRest,
  className,
  style,
  ref,
  ...props
}: ShaderTransitionProps) {
  const pair = transitionKey === undefined && (from !== undefined || to !== undefined);
  const incoming: Slot = pair
    ? { key: active ? "to" : "from", node: active ? to : from }
    : { key: `key:${String(transitionKey ?? "")}`, node: children };

  const [state, setState] = useState<MachineState>(() => ({
    shown: incoming,
    leaving: null,
    status: "idle",
    phase: "cover",
    mode: "shader",
    run: 0,
  }));

  // Derive the machine from props during render (React's "adjust state on prop
  // change" pattern), so the outgoing state is never missing for a frame.
  if (incoming.key !== state.shown.key) {
    if (state.leaving && incoming.key === state.leaving.key) {
      // Back to where the run started: cancel it.
      setState({
        ...state,
        shown: incoming,
        leaving: null,
        status: "done",
        run: state.run + 1,
      });
    } else if (
      state.status === "running" &&
      state.mode === "shader" &&
      state.phase === "cover"
    ) {
      // Retarget under the cover: the same run carries on to the newer state.
      setState({ ...state, shown: incoming });
    } else {
      setState({
        shown: incoming,
        leaving: state.shown,
        status: "running",
        phase: "cover",
        mode: "shader",
        run: state.run + 1,
      });
    }
  } else if (incoming.node !== state.shown.node) {
    setState({ ...state, shown: incoming });
  }

  const rootRef = useRef<HTMLDivElement | null>(null);
  const hostRef = useRef<HTMLDivElement | null>(null);
  const settledRun = useRef(0);
  const enteringRef = useRef<HTMLDivElement | null>(null);
  const onScreen = useRef(true);
  const latest = useRef({
    preset,
    duration,
    surface,
    accent,
    accentSecondary,
    onStart,
    onSwap,
    onRest,
  });
  // A layout effect, declared before the run's, so a run starting in this
  // commit already sees this render's preset and callbacks.
  useLayoutEffect(() => {
    latest.current = {
      preset,
      duration,
      surface,
      accent,
      accentSecondary,
      onStart,
      onSwap,
      onRest,
    };
  });

  // Know whether the frame is visible, so a run nobody can see is skipped.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || typeof IntersectionObserver !== "function") return;
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) onScreen.current = entry.isIntersecting;
    });
    observer.observe(root);
    return () => {
      observer.disconnect();
    };
  }, []);

  const { run, status } = state;

  useLayoutEffect(() => {
    const root = rootRef.current;
    const options = latest.current;
    if (!root) return;
    if (status !== "running") {
      // A run that ended in the render phase (a cancel) still settles.
      if (run > 0 && settledRun.current !== run) {
        settledRun.current = run;
        options.onSwap?.();
        options.onRest?.();
      }
      return;
    }

    let raf = 0;
    let timer: ReturnType<typeof setTimeout> | undefined;
    let renderer: ShaderRenderer | null = null;
    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        renderer?.resize(entry.contentRect.width, entry.contentRect.height);
      }
    });
    let swapped = false;
    let settled = false;
    let canvas: HTMLCanvasElement | null = null;

    const swap = () => {
      if (swapped) return;
      swapped = true;
      latest.current.onSwap?.();
    };

    const teardown = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      if (timer) clearTimeout(timer);
      timer = undefined;
      resizeObserver.disconnect();
      canvas?.removeEventListener("webglcontextlost", onLost);
      renderer?.destroy();
      renderer = null;
      canvas?.remove();
      canvas = null;
    };

    const finish = () => {
      if (settled) return;
      settled = true;
      settledRun.current = run;
      teardown();
      swap();
      setState((current) =>
        current.run === run
          ? { ...current, leaving: null, status: "done", phase: "cover", mode: "shader" }
          : current,
      );
      latest.current.onRest?.();
    };

    const fade = (ms: number) => {
      teardown();
      setState((current) => (current.run === run ? { ...current, mode: "fade" } : current));
      swap();
      timer = setTimeout(finish, ms);
    };

    function onLost(event: Event) {
      // Let the browser restore the context; this run finishes as a fade and
      // the next run builds a fresh context on a fresh canvas.
      event.preventDefault();
      fade(remaining * FADE_SHARE);
    }

    options.onStart?.();

    // Keep keyboard focus out of the outgoing layer once it goes inert.
    const leavingLayer = root.querySelector(
      '[data-slot="shader-transition-layer"][data-state="leaving"]',
    );
    if (leavingLayer?.contains(document.activeElement)) {
      enteringRef.current?.focus({ preventScroll: true });
    }

    const scale = motionScale(root);
    const presetData = getShaderTransitionPreset(options.preset);
    const total = Math.max(0, (options.duration ?? presetData.duration) * scale);
    let remaining = total;

    if (prefersReducedMotion(root) || total <= 0 || !onScreen.current) {
      finish();
      return teardown;
    }

    // A fresh canvas per run (and per effect pass, so StrictMode's double
    // invocation never meets a context the first pass already released).
    const host = hostRef.current;
    if (host && supportsWebGL()) {
      canvas = document.createElement("canvas");
      canvas.setAttribute("data-slot", "shader-transition-canvas");
      canvas.style.cssText = "display:block;width:100%;height:100%";
      host.appendChild(canvas);
      const surfaceRgb = resolveRgb(root, options.surface, [1, 1, 1]);
      const accentRgb = resolveRgb(root, options.accent);
      renderer = createShaderRenderer(canvas, presetData, {
        colors: {
          surface: surfaceRgb,
          accent: accentRgb,
          accentSecondary: options.accentSecondary
            ? resolveRgb(root, options.accentSecondary)
            : mix(surfaceRgb, accentRgb, 0.4),
        },
        flip: window.getComputedStyle(root).direction === "rtl",
      });
      if (!renderer && !canvas.getContext("webgl")) markWebGLUnavailable();
    }

    if (!renderer || !canvas) {
      fade(total * FADE_SHARE);
      return teardown;
    }

    const active = renderer;
    const box = root.getBoundingClientRect();
    active.resize(box.width, box.height);
    resizeObserver.observe(root);
    canvas.addEventListener("webglcontextlost", onLost);

    let elapsed = 0;
    let last: number | null = null;
    const tick = (now: number) => {
      raf = 0;
      if (!onScreen.current) {
        finish();
        return;
      }
      // Deltas are clamped, so a hidden tab (no frames) resumes where it left off.
      const delta = last === null ? 0 : Math.min(now - last, 1000 / 15);
      last = now;
      elapsed += delta;
      remaining = Math.max(0, total - elapsed);
      const t = elapsed / total;
      let progress = easeRun(t);
      if (progress >= 0.5 && !swapped) {
        // Draw this frame fully covered; the states swap underneath it.
        progress = 0.5;
        swap();
        setState((current) =>
          current.run === run ? { ...current, phase: "reveal" } : current,
        );
      }
      active.draw(progress, elapsed / 1000);
      if (t >= 1) {
        finish();
        return;
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    const onVisibility = () => {
      last = null;
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      document.removeEventListener("visibilitychange", onVisibility);
      teardown();
    };
  }, [run, status]);

  const setRootRef = useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node;
      if (typeof ref === "function") return ref(node);
      if (ref) ref.current = node;
      return undefined;
    },
    [ref],
  );

  const running = state.status === "running";
  const baseDuration = duration ?? getShaderTransitionPreset(preset).duration;
  const rootStyle = {
    "--shader-transition-fade": `calc(${String(Math.round(baseDuration * FADE_SHARE))}ms * var(--motion-scale, 1))`,
    ...style,
  } as CSSProperties;

  return (
    <>
      <style href="dowel-shader-transition" precedence="dowel">
        {STYLES}
      </style>
      <div
        data-slot="shader-transition"
        data-state={state.status}
        data-preset={preset}
        data-mode={running ? state.mode : undefined}
        data-phase={running && state.mode === "shader" ? state.phase : undefined}
        aria-busy={running || undefined}
        {...props}
        ref={setRootRef}
        style={rootStyle}
        className={cn("relative isolate grid overflow-hidden", className)}
      >
        {state.leaving ? (
          <div
            key={`layer-${state.leaving.key}`}
            data-slot="shader-transition-layer"
            data-state="leaving"
            aria-hidden="true"
            inert
            className="col-start-1 row-start-1 min-w-0"
          >
            {state.leaving.node}
          </div>
        ) : null}
        <div
          key={`layer-${state.shown.key}`}
          ref={enteringRef}
          data-slot="shader-transition-layer"
          data-state={running ? "entering" : "current"}
          tabIndex={running ? -1 : undefined}
          className="col-start-1 row-start-1 min-w-0 outline-none"
        >
          {state.shown.node}
        </div>
        {running && state.mode === "shader" ? (
          <div
            ref={hostRef}
            aria-hidden="true"
            data-slot="shader-transition-overlay"
            className="pointer-events-none absolute inset-0 z-10"
          />
        ) : null}
      </div>
    </>
  );
}
ui/shader-transition-engine.ts
// Ported from SmoothUI "Shader Reveal Transition" engine (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.

import type { ShaderTransitionPreset } from "./shader-transition-presets";

/*
 * The GL half of ShaderTransition: no React. What is taken from SmoothUI is the
 * engine's shape — one full-frame quad, a fragment shader driven by a single
 * `progress` uniform, premultiplied-alpha blending over the DOM, and a content
 * swap at the midpoint. The fragment program itself is written here: SmoothUI's
 * own fragment code for the shader-reveal and SDF families is not used (see
 * shader-transition-presets.ts for why).
 *
 * The model. The canvas never shows content — it paints a *cover* in the
 * surface colour. For the first half of the run the cover grows over the
 * outgoing state along the preset's arrival field; at the midpoint the whole
 * frame is covered and the states swap underneath; for the second half the
 * cover recedes along the same field (or its reverse) and uncovers the incoming
 * state. A preset is therefore just a scalar field `field(uv, p, t) → [0, 1]`,
 * "when is this pixel covered", plus a `sheen` term that tints the cover with
 * the secondary accent and a glow on the moving front in the primary accent.
 *
 * Trade-off, documented because it is the whole design: a true per-pixel
 * cross-reveal (old and new state both visible through a mask) needs the canvas
 * to mask DOM, and the only portable route is `mask-image` fed from
 * `canvas.toDataURL()` every frame — a PNG encode per frame, far too slow at
 * frame size. Painting a cover keeps both states as live, accessible DOM at the
 * cost of never showing old and new pixels side by side.
 */

export type Rgb = readonly [number, number, number];

export interface ShaderColors {
  /** The cover: the colour behind the content. */
  surface: Rgb;
  /** Glow on the moving front. */
  accent: Rgb;
  /** Tint mixed into the cover by the preset's sheen. */
  accentSecondary: Rgb;
}

export interface ShaderRenderer {
  /** Draws one frame. `progress` runs 0 → 1 across the whole run. */
  draw: (progress: number, time: number) => void;
  /** Resizes the backing store to a CSS box, DPR capped at 2. */
  resize: (width: number, height: number) => void;
  /** Frees the program, buffer and — where the browser allows — the context. */
  destroy: () => void;
}

export const MAX_DPR = 2;

/** Shared by every preset. Varyings are unnecessary: the fragment reads gl_FragCoord. */
export const VERTEX_SHADER =
  "attribute vec2 aPos;\nvoid main(){ gl_Position = vec4(aPos, 0.0, 1.0); }\n";

/** Helpers every preset's GLSL may call: value noise, fbm, a soft minimum. */
const PRELUDE = `
precision mediump float;
uniform vec2 uRes;
uniform float uTime;
uniform float uProgress;
uniform float uSoft;
uniform float uGlow;
uniform float uSheen;
uniform float uReverse;
uniform float uFlip;
uniform vec3 uSurface;
uniform vec3 uAccent;
uniform vec3 uAccent2;
#define PI 3.14159265359
#define TAU 6.28318530718
float hash21(vec2 q){ q = fract(q * vec2(233.34, 851.73)); q += dot(q, q + 23.45); return fract(q.x * q.y); }
float vnoise(vec2 q){
  vec2 i = floor(q); vec2 f = fract(q);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),
             mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 q){
  float v = 0.0; float a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * vnoise(q); q = q * 2.07 + vec2(11.3, 7.9); a *= 0.5; }
  return v;
}
float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
float rmax(){ return length(vec2(uRes.x / uRes.y, 1.0) * 0.5); }
float tri(float x){ return abs(fract(x) * 2.0 - 1.0); }
`;

const MAIN = `
void main(){
  vec2 uv = gl_FragCoord.xy / uRes;
  uv.x = mix(uv.x, 1.0 - uv.x, uFlip);
  vec2 p = (uv - 0.5) * vec2(uRes.x / uRes.y, 1.0);
  float f = clamp(field(uv, p, uTime), 0.0, 1.0);
  bool covering = uProgress < 0.5;
  float q = covering ? uProgress * 2.0 : uProgress * 2.0 - 1.0;
  float x = q * (1.0 + uSoft);
  float g = covering ? f : mix(f, 1.0 - f, uReverse);
  float c = covering ? 1.0 - smoothstep(x - uSoft, x, g) : smoothstep(x - uSoft, x, g);
  float d = g - x;
  float w = uSoft * 0.5 + 0.004;
  float edge = exp(-(d * d) / (w * w)) * smoothstep(0.0, 0.06, q) * smoothstep(1.0, 0.94, q);
  float glow = clamp(edge * uGlow, 0.0, 0.92);
  float tint = clamp(sheen(uv, p, uTime, d) * uSheen, 0.0, 1.0);
  vec3 base = mix(uSurface, uAccent2, tint);
  float cover = c * (1.0 - glow);
  gl_FragColor = vec4(base * cover + uAccent * glow, cover + glow);
}
`;

/** The complete fragment program for one preset. */
export function buildFragmentShader(preset: Pick<ShaderTransitionPreset, "glsl">): string {
  return `${PRELUDE}\n${preset.glsl}\n${MAIN}`;
}

function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    if (process.env.NODE_ENV !== "production") {
      console.warn("[ShaderTransition] shader failed to compile:", gl.getShaderInfoLog(shader));
    }
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

/** Compiles and links the shared vertex shader with a fragment source. */
export function createProgram(
  gl: WebGLRenderingContext,
  fragmentSource: string,
): WebGLProgram | null {
  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
  const fragment = vertex ? compile(gl, gl.FRAGMENT_SHADER, fragmentSource) : null;
  const program = vertex && fragment ? gl.createProgram() : null;
  if (!vertex || !fragment || !program) {
    if (vertex) gl.deleteShader(vertex);
    if (fragment) gl.deleteShader(fragment);
    return null;
  }
  gl.attachShader(program, vertex);
  gl.attachShader(program, fragment);
  gl.linkProgram(program);
  gl.deleteShader(vertex);
  gl.deleteShader(fragment);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program);
    return null;
  }
  return program;
}

const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);

const UNIFORMS = [
  "uRes",
  "uTime",
  "uProgress",
  "uSoft",
  "uGlow",
  "uSheen",
  "uReverse",
  "uFlip",
  "uSurface",
  "uAccent",
  "uAccent2",
] as const;

export interface RendererOptions {
  colors: ShaderColors;
  /** Mirror the field horizontally, so sweeps travel toward inline-end in RTL. */
  flip?: boolean;
}

/**
 * Creates a context and program on `canvas` for one preset. Returns null when
 * WebGL is unavailable or the program does not build — the caller falls back
 * to a CSS cross-fade.
 */
export function createShaderRenderer(
  canvas: HTMLCanvasElement,
  preset: ShaderTransitionPreset,
  { colors, flip = false }: RendererOptions,
): ShaderRenderer | null {
  const context = getWebGL(canvas);
  if (!context) return null;

  const program = createProgram(context, buildFragmentShader(preset));
  if (!program) {
    loseContext(context);
    return null;
  }

  const buffer = context.createBuffer();
  context.useProgram(program);
  context.bindBuffer(context.ARRAY_BUFFER, buffer);
  context.bufferData(context.ARRAY_BUFFER, QUAD, context.STATIC_DRAW);
  const position = context.getAttribLocation(program, "aPos");
  context.enableVertexAttribArray(position);
  context.vertexAttribPointer(position, 2, context.FLOAT, false, 0, 0);
  context.enable(context.BLEND);
  context.blendFunc(context.ONE, context.ONE_MINUS_SRC_ALPHA);
  context.clearColor(0, 0, 0, 0);

  const loc = Object.fromEntries(
    UNIFORMS.map((name) => [name, context.getUniformLocation(program, name)]),
  ) as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;

  context.uniform1f(loc.uSoft, preset.softness);
  context.uniform1f(loc.uGlow, preset.glow);
  context.uniform1f(loc.uSheen, preset.sheen);
  context.uniform1f(loc.uReverse, preset.reverse ? 1 : 0);
  context.uniform1f(loc.uFlip, flip ? 1 : 0);
  context.uniform3f(loc.uSurface, ...colors.surface);
  context.uniform3f(loc.uAccent, ...colors.accent);
  context.uniform3f(loc.uAccent2, ...colors.accentSecondary);

  let destroyed = false;

  return {
    resize(width, height) {
      if (destroyed) return;
      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
      const w = Math.max(1, Math.round(width * dpr));
      const h = Math.max(1, Math.round(height * dpr));
      if (canvas.width !== w) canvas.width = w;
      if (canvas.height !== h) canvas.height = h;
      context.viewport(0, 0, w, h);
      context.uniform2f(loc.uRes, w, h);
    },
    draw(progress, time) {
      if (destroyed || context.isContextLost()) return;
      context.clear(context.COLOR_BUFFER_BIT);
      context.uniform1f(loc.uProgress, clamp01(progress));
      context.uniform1f(loc.uTime, time);
      context.drawArrays(context.TRIANGLE_STRIP, 0, 4);
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      if (!context.isContextLost()) {
        context.deleteBuffer(buffer);
        context.deleteProgram(program);
      }
      loseContext(context);
    },
  };
}

function getWebGL(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
  try {
    return canvas.getContext("webgl", {
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      premultipliedAlpha: true,
      preserveDrawingBuffer: false,
    });
  } catch {
    return null;
  }
}

/** Releases the context now rather than whenever the canvas is collected. */
function loseContext(gl: WebGLRenderingContext) {
  gl.getExtension("WEBGL_lose_context")?.loseContext();
}

export function clamp01(value: number): number {
  return Math.min(1, Math.max(0, value));
}

/** Symmetric ease for the whole run: slow in, fast through the swap, slow out. */
export function easeRun(t: number): number {
  const x = clamp01(t);
  return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
}

/* Colour ------------------------------------------------------------------ */

/** `"primary"` → `var(--color-primary)`; `--x` → `var(--x)`; anything else as is. */
export function tokenToCss(token: string): string {
  if (token.startsWith("--")) return `var(${token})`;
  if (/^[a-z][a-z0-9-]*$/.test(token) && token !== "transparent" && token !== "currentcolor") {
    return `var(--color-${token})`;
  }
  return token;
}

let scratch: CanvasRenderingContext2D | null | undefined;

/**
 * Resolves a colour token, as seen from `element`, to linear 0–1 RGB for a
 * uniform. The browser resolves the token (so a scoped theme, nested var() and
 * color-mix() all work) and a 1×1 2D canvas converts whatever colour space it
 * came back in — oklch included — to sRGB bytes.
 */
export function resolveRgb(
  element: Element,
  token: string,
  fallback: Rgb = [0.5, 0.5, 0.5],
): Rgb {
  const doc = element.ownerDocument;
  const view = doc.defaultView;
  if (!view) return fallback;
  const probe = doc.createElement("span");
  probe.style.display = "none";
  probe.style.color = tokenToCss(token);
  (element.parentElement ?? doc.body).appendChild(probe);
  const computed = view.getComputedStyle(probe).color;
  probe.remove();
  if (!computed) return fallback;

  if (scratch === undefined) {
    try {
      const canvas = doc.createElement("canvas");
      canvas.width = 1;
      canvas.height = 1;
      scratch = canvas.getContext("2d", { willReadFrequently: true });
    } catch {
      scratch = null;
    }
  }
  if (scratch) {
    scratch.clearRect(0, 0, 1, 1);
    scratch.fillStyle = computed;
    scratch.fillRect(0, 0, 1, 1);
    const [r = 0, g = 0, b = 0, a = 0] = scratch.getImageData(0, 0, 1, 1).data;
    if (a > 0) return [r / 255, g / 255, b / 255];
  }
  // No 2D canvas: read the channels from an sRGB serialisation, if that is what came back.
  if (computed.startsWith("rgb")) {
    const [r, g, b] = (computed.match(/[\d.]+/g) ?? []).map(Number);
    if (r !== undefined && g !== undefined && b !== undefined)
      return [r / 255, g / 255, b / 255];
  }
  return fallback;
}

/** Test hook: forget the cached 2D context. */
export function resetColorCache() {
  scratch = undefined;
}

/* Motion preferences ------------------------------------------------------ */

export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

/** The theme's `--motion-scale` (1 when unset). 0 means motion is off. */
export function motionScale(element?: Element): number {
  if (typeof window === "undefined") return 1;
  const raw = window
    .getComputedStyle(element ?? document.documentElement)
    .getPropertyValue("--motion-scale");
  const value = Number.parseFloat(raw);
  return Number.isFinite(value) ? Math.max(0, value) : 1;
}

/** OS reduced-motion preference, or the theme's scale turned (nearly) to zero. */
export function prefersReducedMotion(element?: Element): boolean {
  if (typeof window === "undefined") return false;
  if (
    typeof window.matchMedia === "function" &&
    window.matchMedia(REDUCED_MOTION_QUERY).matches
  ) {
    return true;
  }
  return motionScale(element) < 0.01;
}

/** Whether a WebGL context can be created here at all. Cached per page. */
let webglSupport: boolean | undefined;
export function supportsWebGL(): boolean {
  if (webglSupport !== undefined) return webglSupport;
  if (typeof document === "undefined") return false;
  try {
    webglSupport = typeof WebGLRenderingContext !== "undefined";
  } catch {
    webglSupport = false;
  }
  return webglSupport;
}

/** Records that creating a context failed, so later runs skip straight to the fallback. */
export function markWebGLUnavailable() {
  webglSupport = false;
}

/** Test hook: forget the cached support check. */
export function resetWebGLSupport() {
  webglSupport = undefined;
}
ui/shader-transition-presets.ts
// Ported from SmoothUI "Aperture Blur", "Chroma Blur" and "Prism Sweep" transitions (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.

/*
 * The preset table. A preset is data: GLSL defining two functions, and the
 * uniforms the shared program reads.
 *
 *   float field(vec2 uv, vec2 p, float t)          when this pixel is covered, 0 → 1
 *   float sheen(vec2 uv, vec2 p, float t, float d)  secondary-accent tint of the cover;
 *                                                   d is the signed distance to the front
 *
 * `uv` is 0–1 across the frame (x mirrored in RTL), `p` is centred and
 * aspect-corrected (x spans ±aspect/2, y ±1/2), `t` is seconds. Every preset
 * links against one vertex shader and one prelude (hash21, vnoise, fbm, smin,
 * rmax, tri) in shader-transition-engine.ts.
 *
 * Provenance. Only three presets port SmoothUI shader code — aperture-blur,
 * chroma-blur and prism-sweep, SmoothUI's own designs — re-expressed as a field
 * and a sheen, with its literal palette replaced by theme tokens. SmoothUI's
 * other thirteen transitions come from Codrops material, whose licence forbids
 * redistribution: radial-circles, sdf-circle, warped-circle and organic-merge
 * are stages of the Codrops SDF shader progression (SmoothUI's docs say so),
 * sdf-blob is the engine that hosts those stages, and the eight shader-reveal
 * variants are SmoothUI's adaptations of Yuri Artiukh's "Demo 1–8" WebGL image
 * transitions published on Codrops (SmoothUI's changelog calls them the "Akella
 * transitions"). For those thirteen the shaders below are original, written
 * from each effect's description without reading the source shader, and each is
 * marked "original shader (effect inspired by SmoothUI <name>; no code
 * referenced)".
 */

export interface ShaderTransitionPreset {
  /** The SmoothUI component this preset reproduces. */
  source: string;
  /** Whether the shader is ported from SmoothUI or written from scratch. */
  origin: "ported" | "original";
  /** One line for a gallery caption. */
  description: string;
  /** Default run length in ms at `--motion-scale: 1`. */
  duration: number;
  /** Width of the front in field units (0–1). */
  softness: number;
  /** Strength of the primary-accent glow on the front. */
  glow: number;
  /** Strength of the preset's secondary-accent sheen on the cover. */
  sheen: number;
  /** Uncover along the reversed field (the cover retreats) instead of continuing. */
  reverse: boolean;
  /** Defines `field` and `sheen`. */
  glsl: string;
}

/** Max over the frame's four corners of a centred-space shape function. */
const corners = (fn: string) =>
  `vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5; float far = max(max(${fn}(hh), ${fn}(-hh)), max(${fn}(vec2(hh.x, -hh.y)), ${fn}(vec2(-hh.x, hh.y))));`;

export const SHADER_TRANSITION_PRESETS = {
  /* SmoothUI's own designs, ported --------------------------------------- */

  /** Ported: SmoothUI Aperture Blur — a ring opening from the centre, angular ripple, grain. */
  "aperture-blur": {
    source: "ApertureBlurTransition",
    origin: "ported",
    description: "An aperture opens from the centre, its rim rippling and glowing.",
    duration: 900,
    softness: 0.1,
    glow: 1,
    sheen: 0.55,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float a = atan(p.y, p.x);
  float n = sin(a * 8.0 + t * 0.9) * 0.018 + sin(a * 17.0 - t * 0.6) * 0.006;
  return (length(p) - n) / (rmax() + 0.02);
}
float sheen(vec2 uv, vec2 p, float t, float d){
  float swirl = smoothstep(-0.4, 0.4, sin(atan(p.y, p.x) * 2.0 + t * 0.7));
  float grain = hash21(floor(uv * uRes / 3.0) + floor(t * 18.0)) * 0.3;
  return swirl * exp(-d * d * 18.0) + grain * exp(-d * d * 40.0);
}`,
  },

  /** Ported: SmoothUI Chroma Blur — a diagonal curtain with rippling edge and chromatic fringes. */
  "chroma-blur": {
    source: "ChromaBlurTransition",
    origin: "ported",
    description: "A soft diagonal curtain with a rippling edge and chromatic fringes.",
    duration: 1040,
    softness: 0.16,
    glow: 0.45,
    sheen: 0.7,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float n = vnoise(uv * 10.0 + vec2(t * 0.28, -t * 0.18));
  float wave = sin((uv.y + n * 0.12) * 28.0 + t * 2.2) * 0.018;
  return (uv.x * 0.62 + uv.y * 0.38 + wave + 0.02) / 1.04;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  float a = d + 0.045;
  float b = d - 0.045;
  return exp(-a * a * 52.0) + exp(-b * b * 52.0) + vnoise(uv * 34.0 + t * 0.18) * 0.1;
}`,
  },

  /** Ported: SmoothUI Prism Sweep — a faceted glass band sweeping across, caustics behind the crest. */
  "prism-sweep": {
    source: "PrismSweepTransition",
    origin: "ported",
    description: "A faceted prism band sweeps across with caustics behind its crest.",
    duration: 1240,
    softness: 0.08,
    glow: 0.9,
    sheen: 0.9,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float c = t * 0.42;
  float drift = sin(uv.y * 7.0 + c * 1.8) * 0.024 + sin(uv.y * 17.0 - c * 1.1 + 1.6) * 0.010
    + sin((uv.x + uv.y) * 11.0 + c * 0.7) * 0.006;
  return (uv.x + drift * 0.76 + 0.03) / 1.06;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  float c = t * 0.42;
  float facets = smoothstep(0.18, 0.92, tri(uv.y * 12.0 + uv.x * 2.1 + c * 0.35));
  float diagonal = tri((uv.y - uv.x * 0.36) * 5.3 - c * 0.2);
  float caustic = pow(1.0 - diagonal, 4.0) * 0.42 + pow(facets, 2.2) * 0.22;
  float ridge = exp(-(d + 0.05) * (d + 0.05) * 600.0);
  return (caustic + ridge * 0.5) * exp(-d * d * 8.0);
}`,
  },

  /* SDF family: original shaders ----------------------------------------- */

  /** original shader (effect inspired by SmoothUI SdfBlobTransition; no code referenced) */
  "sdf-blob": {
    source: "SdfBlobTransition",
    origin: "original",
    description: "Three soft blobs swell from different points and melt into one.",
    duration: 1120,
    softness: 0.06,
    glow: 0.7,
    sheen: 0.4,
    reverse: false,
    glsl: `
float blobs(vec2 p){
  float a = length(p - vec2(-0.32, 0.18));
  float b = length(p - vec2(0.28, -0.16));
  float c = length(p - vec2(0.06, 0.34));
  return smin(smin(a, b, 0.22), c, 0.22);
}
float field(vec2 uv, vec2 p, float t){
  ${corners("blobs")}
  return (blobs(p) + (fbm(p * 3.0 + t * 0.2) - 0.5) * 0.12) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return exp(-d * d * 30.0) * fbm(p * 6.0 - t * 0.3);
}`,
  },

  /** original shader (effect inspired by SmoothUI OrganicMergeTransition; no code referenced) */
  "organic-merge": {
    source: "OrganicMergeTransition",
    origin: "original",
    description: "Two organic shapes grow from opposite corners and fuse where they meet.",
    duration: 1120,
    softness: 0.06,
    glow: 0.65,
    sheen: 0.6,
    reverse: false,
    glsl: `
float pair(vec2 p){
  vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5;
  float a = length(p + hh * 0.55);
  float b = length((p - hh * 0.55) * vec2(0.8, 1.25));
  return smin(a, b, 0.3);
}
float field(vec2 uv, vec2 p, float t){
  ${corners("pair")}
  return (pair(p) + (vnoise(p * 4.0 + t * 0.3) - 0.5) * 0.08) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5;
  float seam = abs(length(p + hh * 0.55) - length((p - hh * 0.55) * vec2(0.8, 1.25)));
  return exp(-seam * 8.0) * exp(-d * d * 20.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI SdfCircleTransition; no code referenced) */
  "sdf-circle": {
    source: "SdfCircleTransition",
    origin: "original",
    description: "A clean circle grows from the centre with a crisp edge.",
    duration: 1000,
    softness: 0.025,
    glow: 0.55,
    sheen: 0,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){ return length(p) / rmax(); }
float sheen(vec2 uv, vec2 p, float t, float d){ return 0.0; }`,
  },

  /** original shader (effect inspired by SmoothUI WarpedCircleTransition; no code referenced) */
  "warped-circle": {
    source: "WarpedCircleTransition",
    origin: "original",
    description: "A circle whose perimeter waves as it grows.",
    duration: 1120,
    softness: 0.04,
    glow: 0.7,
    sheen: 0.35,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float a = atan(p.y, p.x);
  float warp = sin(a * 6.0 + t * 1.6) * 0.035 + sin(a * 11.0 - t * 2.1) * 0.015;
  return (length(p) + warp) / (rmax() + 0.05) + 0.02;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return (0.5 + 0.5 * sin(atan(p.y, p.x) * 6.0 + t * 1.6)) * exp(-d * d * 24.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI RadialCirclesTransition; no code referenced) */
  "radial-circles": {
    source: "RadialCirclesTransition",
    origin: "original",
    description: "A grid of dots swells into circles, radiating out from the centre.",
    duration: 1120,
    softness: 0.03,
    glow: 0.3,
    sheen: 0.5,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float s = 0.11;
  vec2 ctr = (floor(p / s) + 0.5) * s;
  return length(ctr) / rmax() * 0.62 + length(p - ctr) / (s * 0.7072) * 0.38;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  vec2 cell = floor(p / 0.11);
  return step(0.5, fract((cell.x + cell.y) * 0.5)) * exp(-d * d * 25.0);
}`,
  },

  /* Shader-reveal family: original shaders ------------------------------- */

  /** original shader (effect inspired by SmoothUI ShaderRevealNoiseTransition; no code referenced) */
  noise: {
    source: "ShaderRevealNoiseTransition",
    origin: "original",
    description: "A turbulent noise threshold: the cover arrives in organic patches.",
    duration: 1080,
    softness: 0.08,
    glow: 0.55,
    sheen: 0.45,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  return (fbm(p * 3.2 + vec2(t * 0.12, -t * 0.08)) - 0.22) / 0.56;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return fbm(p * 9.0 + t * 0.4) * exp(-d * d * 30.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealZoomTransition; no code referenced) */
  zoom: {
    source: "ShaderRevealZoomTransition",
    origin: "original",
    description: "A lens swells from the centre, then the frame zooms back through it.",
    duration: 1080,
    softness: 0.07,
    glow: 0.6,
    sheen: 0.5,
    reverse: true,
    glsl: `
float lens(vec2 p){ return length((p - vec2(0.0, -0.08)) * vec2(1.0, 1.25)); }
float field(vec2 uv, vec2 p, float t){
  ${corners("lens")}
  return lens(p) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return (0.5 + 0.5 * sin(length(p) * 48.0 - t * 3.0)) * exp(-d * d * 14.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealCircleTransition; no code referenced) */
  circle: {
    source: "ShaderRevealCircleTransition",
    origin: "original",
    description: "A circle with a noisy, smoky rim spreads from the centre.",
    duration: 1080,
    softness: 0.09,
    glow: 0.6,
    sheen: 0.4,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  return (length(p) + (fbm(p * 4.0 + t * 0.25) - 0.5) * 0.22) / (rmax() + 0.06) + 0.03;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return fbm(p * 7.0 - t * 0.3) * exp(-d * d * 22.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealWipeTransition; no code referenced) */
  wipe: {
    source: "ShaderRevealWipeTransition",
    origin: "original",
    description: "A narrow, noise-displaced blade wipes across toward the inline end.",
    duration: 1000,
    softness: 0.025,
    glow: 1,
    sheen: 0.3,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  return uv.x * 0.9 + (fbm(vec2(uv.y * 6.0, t * 0.35)) - 0.5) * 0.1 + 0.05;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return vnoise(vec2(uv.y * 40.0, t)) * exp(-d * d * 60.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealLumaTransition; no code referenced) */
  luma: {
    source: "ShaderRevealLumaTransition",
    origin: "original",
    description: "Horizontal ribbons pour down from the top, each on its own delay.",
    duration: 1080,
    softness: 0.05,
    glow: 0.4,
    sheen: 0.45,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float row = floor(uv.y * 12.0);
  return hash21(vec2(row, 7.0)) * 0.4 + (1.0 - uv.y) * 0.35 + (1.0 - fract(uv.y * 12.0)) * 0.25;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return mod(floor(uv.y * 12.0), 2.0) * exp(-d * d * 12.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealPlanetaryTransition; no code referenced) */
  planetary: {
    source: "ShaderRevealPlanetaryTransition",
    origin: "original",
    description: "Spiral arms wind outward from the centre like a vortex.",
    duration: 1180,
    softness: 0.06,
    glow: 0.6,
    sheen: 0.5,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float r = length(p) / rmax();
  float arm = fract((atan(p.y, p.x) / TAU + 0.5) * 2.0 + r * 1.1 - t * 0.04);
  return r * 0.55 + arm * 0.45;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return (0.5 + 0.5 * sin(atan(p.y, p.x) * 4.0 + length(p) * 18.0 - t * 2.0)) * exp(-d * d * 18.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealStripesTransition; no code referenced) */
  stripes: {
    source: "ShaderRevealStripesTransition",
    origin: "original",
    description: "Slanted bars of random width snap shut like a barcode shutter.",
    duration: 1000,
    softness: 0.015,
    glow: 0.35,
    sheen: 0.4,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float s = (uv.x + uv.y * 0.35) / 1.35;
  return s * 0.55 + hash21(vec2(floor(s * 26.0), 3.1)) * 0.45;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  float s = (uv.x + uv.y * 0.35) / 1.35;
  return step(0.5, hash21(vec2(floor(s * 26.0), 9.7))) * exp(-d * d * 40.0);
}`,
  },

  /** original shader (effect inspired by SmoothUI ShaderRevealPushTransition; no code referenced) */
  push: {
    source: "ShaderRevealPushTransition",
    origin: "original",
    description: "A noisy front pushes up from the bottom, pulled along by flowing noise.",
    duration: 1080,
    softness: 0.07,
    glow: 0.55,
    sheen: 0.45,
    reverse: false,
    glsl: `
float field(vec2 uv, vec2 p, float t){
  float n = fbm(vec2(uv.x * 3.0, uv.y * 2.0 - t * 0.5));
  return uv.y * 0.8 + (n - 0.5) * 0.36 + 0.1;
}
float sheen(vec2 uv, vec2 p, float t, float d){
  return fbm(vec2(uv.x * 8.0, uv.y * 4.0 - t * 0.8)) * exp(-d * d * 20.0);
}`,
  },
} satisfies Record<string, ShaderTransitionPreset>;

export type ShaderTransitionPresetName = keyof typeof SHADER_TRANSITION_PRESETS;

export const SHADER_TRANSITION_PRESET_NAMES = Object.keys(
  SHADER_TRANSITION_PRESETS,
) as ShaderTransitionPresetName[];

/** The preset for a name; unknown names fall back to `noise` (and warn in development). */
export function getShaderTransitionPreset(name: string): ShaderTransitionPreset {
  const table: Record<string, ShaderTransitionPreset> = SHADER_TRANSITION_PRESETS;
  const preset = table[name];
  if (preset) return preset;
  if (process.env.NODE_ENV !== "production") {
    console.warn(`[ShaderTransition] unknown preset "${name}"; using "noise".`);
  }
  return SHADER_TRANSITION_PRESETS.noise;
}