shadimage
benday
uibendaysource: ok · refreshed

Benday

Turn any logo into a customizable animated Ben-Day dot field.

npx shadcn add "https://benday.kacemmathlouthi.dev/r/benday.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files7 · source cached 10/8/2026, 2:23:26 AM

registry/ui/benday.tsx
"use client";

import type { CSSProperties, CanvasHTMLAttributes } from "react";
import { useEffect, useRef } from "react";

import type { BakeSource } from "./benday/bake";
import { createRenderer } from "./benday/renderer";
import type {
  BakeOptions,
  BendayState,
  DotMap,
  Renderer,
  RendererOptions,
} from "./benday/types";
import { useDotMap } from "./benday/use-dot-map";

export interface BendayProps
  extends
    Omit<
      CanvasHTMLAttributes<HTMLCanvasElement>,
      "color" | "height" | "style" | "width"
    >,
    Omit<RendererOptions, "dotMap"> {
  /** Image to bake at runtime — URL, data URI, File/Blob, or a loaded image. */
  src?: BakeSource;
  /** A pre-baked dot map. Takes precedence over `src`; this is the build-time path. */
  dotMap?: DotMap;
  /** Options for the runtime bake. Ignored when `dotMap` is supplied. */
  bake?: BakeOptions;
  style?: CSSProperties;
}

const STATE_LABEL: Record<BendayState, string> = {
  done: "Done",
  idle: "Idle",
  thinking: "Thinking…",
};

/**
 * A canvas indicator built from your logo. This owns only the element, the bake
 * and the props; `renderer.update()` takes changes without restarting anything.
 */
export function Benday({
  src,
  dotMap: dotMapProp,
  bake: bakeOptions,
  preset = "contour",
  state = "thinking",
  size = 64,
  fit = "square",
  speed = 1,
  color = "currentColor",
  dotScale = 0.62,
  shape = "circle",
  glow = 0,
  padding = 0.06,
  weight = 0.5,
  paused = false,
  reducedMotion = "auto",
  style,
  "aria-label": ariaLabel,
  ...rest
}: BendayProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rendererRef = useRef<Renderer | null>(null);

  // Only bake internally when no map was handed in.
  const baked = useDotMap(dotMapProp ? null : src, bakeOptions);
  const dotMap = dotMapProp ?? baked.dotMap;

  const options: RendererOptions = {
    color,
    dotMap,
    dotScale,
    fit,
    glow,
    padding,
    paused,
    preset,
    reducedMotion,
    shape,
    size,
    speed,
    state,
    weight,
  };

  // Declared before the mount effect so it has already run when the renderer is
  // created — writing a ref during render is what React Compiler rules out.
  const optionsRef = useRef(options);
  useEffect(() => {
    optionsRef.current = options;
  });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) {
      return;
    }
    const renderer = createRenderer(canvas, optionsRef.current);
    rendererRef.current = renderer;
    return () => {
      renderer.destroy();
      rendererRef.current = null;
    };
  }, []);

  useEffect(() => {
    rendererRef.current?.update({
      color,
      dotMap,
      dotScale,
      fit,
      glow,
      padding,
      paused,
      preset,
      reducedMotion,
      shape,
      size,
      speed,
      state,
      weight,
    });
  }, [
    color,
    dotMap,
    dotScale,
    fit,
    glow,
    padding,
    paused,
    preset,
    reducedMotion,
    shape,
    size,
    speed,
    state,
    weight,
  ]);

  // Reserve the box before the renderer sizes the canvas, so nothing shifts.
  const height = fit === "natural" && dotMap ? size / safeAspect(dotMap) : size;

  return (
    <canvas
      aria-label={ariaLabel ?? STATE_LABEL[state]}
      ref={canvasRef}
      role="img"
      style={{ display: "block", height, width: size, ...style }}
      {...rest}
    />
  );
}

export {
  DEFAULT_BAKE,
  bake,
  bakeCached,
  bakeKey,
  clearBakeCache,
  resolveBakeOptions,
} from "./benday/bake";
export type { BakeSource } from "./benday/bake";
export { prefersReducedMotion } from "./benday/dom";
export {
  PRESET_FAMILIES,
  PRESET_NAMES,
  PRESETS,
  dotRandom,
  makeFrame,
} from "./benday/presets";
export type { PresetDefinition, PresetFamily } from "./benday/presets";
export { DEFAULT_RENDERER_OPTIONS, createRenderer } from "./benday/renderer";
export type {
  BakeOptions,
  BendayState,
  Dot,
  DotContext,
  DotFrame,
  DotMap,
  DotShape,
  Fit,
  MaskMode,
  Preset,
  PresetName,
  Renderer,
  RendererOptions,
  ResolvedBakeOptions,
  ResolvedRendererOptions,
} from "./benday/types";
export { useDotMap } from "./benday/use-dot-map";
export type { UseDotMapResult } from "./benday/use-dot-map";

function safeAspect(dotMap: DotMap): number {
  return Number.isFinite(dotMap.aspect) && dotMap.aspect > 0
    ? dotMap.aspect
    : dotMap.cols / Math.max(1, dotMap.rows);
}
registry/ui/benday/bake.ts
import type {
  BakeOptions,
  Dot,
  DotMap,
  MaskMode,
  ResolvedBakeOptions,
} from "./types";

export const DEFAULT_BAKE: Omit<ResolvedBakeOptions, "workingSize"> = {
  dilate: 0,
  gamma: 1,
  grid: 24,
  invert: false,
  maskMode: "auto",
  threshold: 0.18,
  trim: true,
};

const clamp = (v: number, lo: number, hi: number) =>
  Math.min(hi, Math.max(lo, v));
const clamp01 = (v: number) => clamp(v, 0, 1);

export function resolveBakeOptions(
  options: BakeOptions = {}
): ResolvedBakeOptions {
  const merged = { ...DEFAULT_BAKE, ...stripUndefined(options) };
  const workingSize =
    options.workingSize ?? clamp(Math.round(merged.grid * 16), 192, 768);
  return { ...merged, workingSize };
}

function stripUndefined<T extends object>(o: T): Partial<T> {
  const out: Partial<T> = {};
  for (const k of Object.keys(o) as (keyof T)[]) {
    if (o[k] !== undefined) {
      out[k] = o[k];
    }
  }
  return out;
}

export type BakeSource = string | Blob | HTMLImageElement | ImageBitmap;

function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.addEventListener("load", () => resolve(img), { once: true });
    img.addEventListener(
      "error",
      () => reject(new Error(`benday: failed to load image "${src}"`)),
      { once: true }
    );
    img.src = src;
  });
}

/**
 * Rasterize at `workingSize` (longest side). SVGs go straight to the target
 * size — vectors rasterize cleanly at any scale, so no intermediate pass.
 */
async function rasterize(
  source: BakeSource,
  workingSize: number
): Promise<{ data: ImageData; width: number; height: number }> {
  let el: CanvasImageSource;
  let iw = 0;
  let ih = 0;
  let revoke: string | undefined;

  try {
    if (typeof source === "string") {
      const img = await loadImage(source);
      el = img;
      iw = img.naturalWidth || img.width;
      ih = img.naturalHeight || img.height;
    } else if (
      typeof ImageBitmap !== "undefined" &&
      source instanceof ImageBitmap
    ) {
      el = source;
      iw = source.width;
      ih = source.height;
    } else if (source instanceof Blob) {
      revoke = URL.createObjectURL(source);
      const img = await loadImage(revoke);
      el = img;
      iw = img.naturalWidth || img.width;
      ih = img.naturalHeight || img.height;
    } else {
      const img = source as HTMLImageElement;
      el = img;
      iw = img.naturalWidth || img.width;
      ih = img.naturalHeight || img.height;
    }

    // SVGs with only a viewBox report 0×0 in some browsers.
    if (!iw || !ih) {
      iw = 1024;
      ih = 1024;
    }

    const scale = workingSize / Math.max(iw, ih);
    const width = Math.max(1, Math.round(iw * scale));
    const height = Math.max(1, Math.round(ih * scale));

    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    if (!ctx) {
      throw new Error("benday: could not acquire a 2D context");
    }
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "high";
    ctx.drawImage(el, 0, 0, width, height);

    let data: ImageData;
    try {
      data = ctx.getImageData(0, 0, width, height);
    } catch {
      throw new Error(
        "benday: the image tainted the canvas (cross-origin without CORS headers)"
      );
    }
    return { data, height, width };
  } finally {
    if (revoke) {
      URL.revokeObjectURL(revoke);
    }
  }
}

const luma = (r: number, g: number, b: number) =>
  (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;

function hasAlpha(px: Uint8ClampedArray): boolean {
  // A handful of soft edge pixels is enough; a fully opaque photo has none.
  let soft = 0;
  for (let i = 3; i < px.length; i += 4) {
    if (px[i] < 250) {
      soft++;
      if (soft > 8) {
        return true;
      }
    }
  }
  return false;
}

/**
 * Ink coverage per pixel, 0 = background, 1 = solid ink. Luma mode reads the
 * corners for a background level, then measures each pixel's departure.
 */
function buildCoverage(
  img: ImageData,
  width: number,
  height: number,
  mode: "alpha" | "luma",
  invert: boolean
): Float32Array {
  const px = img.data;
  const cov = new Float32Array(width * height);

  if (mode === "alpha") {
    for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
      cov[p] = px[i + 3] / 255;
    }
    return cov;
  }

  const corners = [
    0,
    (width - 1) * 4,
    (height - 1) * width * 4,
    ((height - 1) * width + width - 1) * 4,
  ];
  const cornerLuma = corners
    .map((i) => luma(px[i], px[i + 1], px[i + 2]))
    .toSorted((a, b) => a - b);
  let bg = (cornerLuma[1] + cornerLuma[2]) / 2; // median of four
  let bgIsLight = bg > 0.5;
  if (invert) {
    bgIsLight = !bgIsLight;
  }
  // Guard against a background that sits mid-grey, which would leave no headroom.
  const span = Math.max(0.12, bgIsLight ? bg : 1 - bg);
  if (!bgIsLight) {
    bg = Math.min(bg, 1 - span);
  }

  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
    const l = luma(px[i], px[i + 1], px[i + 2]);
    const ink = bgIsLight ? bg - l : l - bg;
    // Transparent regions in a luma-mode image still read as background.
    cov[p] = clamp01(ink / span) * (px[i + 3] / 255);
  }
  return cov;
}

/**
 * Preserve visible layers independently from silhouette coverage. Alpha tells
 * us where artwork exists, but not whether one opaque region is lighter than
 * another.
 *
 * Tone is each pixel's contrast against the surface the artwork reads against,
 * scaled by the strongest such contrast present. Stretching the source's own
 * range instead answered "where in this artwork's range does the pixel sit",
 * which is a different question: a mid grey in a three-tone mark came out at
 * a tenth of the area its darkness earns. Uniform artwork stays at full tone.
 */
function buildTone(img: ImageData, cov: Float32Array): Float32Array {
  const px = img.data;
  const histogram = new Float64Array(256);
  let total = 0;
  let luminanceSum = 0;

  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
    const coverage = cov[p];
    if (coverage <= 0.02) {
      continue;
    }
    const level = Math.round(luma(px[i], px[i + 1], px[i + 2]) * 255);
    histogram[level] += coverage;
    luminanceSum += level * coverage;
    total += coverage;
  }

  const tone = new Float32Array(cov.length);
  if (total === 0) {
    return tone;
  }

  // White artwork on transparency is as common as black artwork. Let the
  // dominant half of the source decide which surface it contrasts against.
  const reference = luminanceSum / total > 128 ? 0 : 255;
  const contrast = new Float64Array(256);
  for (let level = 0; level < 256; level++) {
    contrast[Math.abs(level - reference)] += histogram[level] as number;
  }
  // A percentile rather than the maximum, so one stray pixel cannot set the scale.
  const strongest = histogramPercentile(contrast, total, 0.98);
  if (strongest <= 0) {
    return tone;
  }

  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {
    if (cov[p] <= 0.02) {
      continue;
    }
    const level = luma(px[i], px[i + 1], px[i + 2]) * 255;
    tone[p] = clamp01(Math.abs(level - reference) / strongest);
  }
  return tone;
}

function histogramPercentile(
  histogram: Float64Array,
  total: number,
  percentile: number
): number {
  const target = total * percentile;
  let seen = 0;
  for (let i = 0; i < histogram.length; i++) {
    seen += histogram[i];
    if (seen >= target) {
      return i;
    }
  }
  return histogram.length - 1;
}

/** Separable box dilation — grows the mask to rescue hairline strokes. */
function dilateCoverage(
  cov: Float32Array,
  width: number,
  height: number,
  r: number
): Float32Array {
  if (r <= 0) {
    return cov;
  }
  const radius = Math.round(r);
  const tmp = new Float32Array(cov.length);
  const out = new Float32Array(cov.length);

  for (let y = 0; y < height; y++) {
    const row = y * width;
    for (let x = 0; x < width; x++) {
      let m = 0;
      const lo = Math.max(0, x - radius);
      const hi = Math.min(width - 1, x + radius);
      for (let k = lo; k <= hi; k++) {
        if (cov[row + k] > m) {
          m = cov[row + k];
        }
      }
      tmp[row + x] = m;
    }
  }
  for (let x = 0; x < width; x++) {
    for (let y = 0; y < height; y++) {
      let m = 0;
      const lo = Math.max(0, y - radius);
      const hi = Math.min(height - 1, y + radius);
      for (let k = lo; k <= hi; k++) {
        const v = tmp[k * width + x];
        if (v > m) {
          m = v;
        }
      }
      out[y * width + x] = m;
    }
  }
  return out;
}

const INF = 1e20;

/** 1D squared distance transform of a sampled function, in place-ish. */
function edt1d(
  f: Float64Array,
  d: Float64Array,
  v: Int32Array,
  z: Float64Array,
  n: number
): void {
  let k = 0;
  v[0] = 0;
  z[0] = -INF;
  z[1] = INF;
  for (let q = 1; q < n; q++) {
    let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
    while (s <= z[k]) {
      k--;
      s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
    }
    k++;
    v[k] = q;
    z[k] = s;
    z[k + 1] = INF;
  }
  k = 0;
  for (let q = 0; q < n; q++) {
    while (z[k + 1] < q) {
      k++;
    }
    const dq = q - v[k];
    d[q] = dq * dq + f[v[k]];
  }
}

/**
 * Exact distance from each inside pixel to the nearest outside one, after
 * Felzenszwalb & Huttenlocher: O(n), and true distance a ripple can order by.
 */
function distanceTransform(
  mask: Uint8Array,
  width: number,
  height: number
): { dist: Float32Array; max: number } {
  const grid = new Float64Array(width * height);
  for (let i = 0; i < grid.length; i++) {
    grid[i] = mask[i] ? INF : 0;
  }

  const n = Math.max(width, height);
  const f = new Float64Array(n);
  const d = new Float64Array(n);
  const v = new Int32Array(n);
  const z = new Float64Array(n + 1);

  for (let y = 0; y < height; y++) {
    const row = y * width;
    for (let x = 0; x < width; x++) {
      f[x] = grid[row + x];
    }
    edt1d(f, d, v, z, width);
    for (let x = 0; x < width; x++) {
      grid[row + x] = d[x];
    }
  }
  for (let x = 0; x < width; x++) {
    for (let y = 0; y < height; y++) {
      f[y] = grid[y * width + x];
    }
    edt1d(f, d, v, z, height);
    for (let y = 0; y < height; y++) {
      grid[y * width + x] = d[y];
    }
  }

  const dist = new Float32Array(width * height);
  let max = 0;
  for (let i = 0; i < dist.length; i++) {
    const val = Math.sqrt(grid[i]);
    dist[i] = val;
    if (val > max) {
      max = val;
    }
  }
  return { dist, max };
}

interface ContentBounds {
  minX: number;
  minY: number;
  boxW: number;
  boxH: number;
}

/** Which masking strategy to run, resolving `auto` against the pixels. */
function resolveMaskMode(
  mode: MaskMode,
  pixels: Uint8ClampedArray
): "alpha" | "luma" {
  if (mode !== "auto") {
    return mode;
  }
  return hasAlpha(pixels) ? "alpha" : "luma";
}

/** Bounding box of the ink; the full frame if trimming is off or it is empty. */
function findContentBounds(
  cov: Float32Array,
  width: number,
  height: number,
  trim: boolean
): ContentBounds {
  let minX = width;
  let minY = height;
  let maxX = -1;
  let maxY = -1;

  if (trim) {
    for (let y = 0; y < height; y++) {
      const row = y * width;
      for (let x = 0; x < width; x++) {
        if (cov[row + x] > 0.06) {
          minX = Math.min(minX, x);
          maxX = Math.max(maxX, x);
          minY = Math.min(minY, y);
          maxY = Math.max(maxY, y);
        }
      }
    }
  }

  if (maxX < minX || maxY < minY) {
    return { boxH: height, boxW: width, minX: 0, minY: 0 };
  }
  return {
    boxH: maxY - minY + 1,
    boxW: maxX - minX + 1,
    minX,
    minY,
  };
}

/** Mean coverage and coverage-weighted depth per cell. */
function sampleDots(
  cov: Float32Array,
  tone: Float32Array,
  dist: Float32Array,
  width: number,
  height: number,
  bounds: ContentBounds,
  cols: number,
  rows: number,
  threshold: number
): Dot[] {
  const cellW = bounds.boxW / cols;
  const cellH = bounds.boxH / rows;
  const dots: Dot[] = [];

  for (let row = 0; row < rows; row++) {
    const y0 = Math.floor(bounds.minY + row * cellH);
    const y1 = Math.max(y0 + 1, Math.floor(bounds.minY + (row + 1) * cellH));

    for (let col = 0; col < cols; col++) {
      const x0 = Math.floor(bounds.minX + col * cellW);
      const x1 = Math.max(x0 + 1, Math.floor(bounds.minX + (col + 1) * cellW));

      let sum = 0;
      let count = 0;
      let weightedDepth = 0;
      let weightedTone = 0;

      for (let y = y0; y < y1 && y < height; y++) {
        const r = y * width;
        for (let x = x0; x < x1 && x < width; x++) {
          sum += cov[r + x];
          weightedDepth += dist[r + x] * cov[r + x];
          weightedTone += tone[r + x] * cov[r + x];
          count++;
        }
      }

      if (count === 0) {
        continue;
      }
      const v = sum / count;
      if (v < threshold) {
        continue;
      }

      dots.push({
        col,
        d: sum > 0 ? weightedDepth / sum : 0,
        row,
        t: sum > 0 ? weightedTone / sum : 1,
        v: clamp01(v),
        x: (col + 0.5) / cols,
        y: (row + 0.5) / rows,
      });
    }
  }

  return dots;
}

/**
 * Normalize against the deepest sampled dot, not the deepest pixel: a hairline
 * mark would otherwise flatten `d` to zero and leave depth presets inert.
 */
function normalizeDepth(dots: Dot[]): void {
  let maxDepth = 0;
  for (const dot of dots) {
    maxDepth = Math.max(maxDepth, dot.d);
  }
  for (const dot of dots) {
    dot.d = maxDepth > 0 ? clamp01(dot.d / maxDepth) : 0;
  }
}

/**
 * Turn an image into a {@link DotMap}. Browser-only: it goes via a canvas. Run
 * it at build time and ship the JSON, or at runtime for any-logo drop-in.
 */
export async function bake(
  source: BakeSource,
  options: BakeOptions = {}
): Promise<DotMap> {
  const opts = resolveBakeOptions(options);
  const { data, width, height } = await rasterize(source, opts.workingSize);
  const mode = resolveMaskMode(opts.maskMode, data.data);

  let cov = buildCoverage(data, width, height, mode, opts.invert);
  const tone = buildTone(data, cov);
  if (opts.gamma !== 1) {
    const g = Math.max(0.05, opts.gamma);
    for (let i = 0; i < cov.length; i++) {
      cov[i] **= g;
    }
  }
  cov = dilateCoverage(cov, width, height, opts.dilate);

  // Binary mask for the distance transform. A low fixed cut keeps antialiased
  // edges inside the shape so the EDT measures the stroke, not the core.
  const mask = new Uint8Array(cov.length);
  for (let i = 0; i < cov.length; i++) {
    mask[i] = cov[i] > 0.5 ? 1 : 0;
  }

  const bounds = findContentBounds(cov, width, height, opts.trim);
  const { dist } = distanceTransform(mask, width, height);

  // Grid the content box so the longest side gets `grid` cells.
  const longest = Math.max(bounds.boxW, bounds.boxH);
  const cols = Math.max(1, Math.round((bounds.boxW / longest) * opts.grid));
  const rows = Math.max(1, Math.round((bounds.boxH / longest) * opts.grid));

  const dots = sampleDots(
    cov,
    tone,
    dist,
    width,
    height,
    bounds,
    cols,
    rows,
    opts.threshold
  );
  normalizeDepth(dots);

  return {
    aspect: bounds.boxW / bounds.boxH,
    cells: cols * rows,
    cols,
    dots,
    maskMode: mode,
    rows,
  };
}

const cache = new Map<string, Promise<DotMap>>();

/** Cache key for a (source, options) pair. Only string sources are cacheable. */
export function bakeKey(src: string, options: BakeOptions = {}): string {
  const o = resolveBakeOptions(options);
  return [
    src,
    o.grid,
    o.threshold,
    o.gamma,
    o.maskMode,
    o.invert ? 1 : 0,
    o.dilate,
    o.trim ? 1 : 0,
    o.workingSize,
  ].join("|");
}

/** A failed bake must not stay cached, or the error is permanent. */
async function bakeAndForgetOnFailure(
  key: string,
  src: string,
  options: BakeOptions
): Promise<DotMap> {
  try {
    return await bake(src, options);
  } catch (error) {
    cache.delete(key);
    throw error;
  }
}

/** {@link bake}, memoized per (url, options). */
export function bakeCached(
  src: string,
  options: BakeOptions = {}
): Promise<DotMap> {
  const key = bakeKey(src, options);
  let hit = cache.get(key);
  if (!hit) {
    hit = bakeAndForgetOnFailure(key, src, options);
    cache.set(key, hit);
  }
  return hit;
}

export function clearBakeCache(): void {
  cache.clear();
}
registry/ui/benday/dom.ts
/** Framework-agnostic DOM plumbing; what keeps the React wrapper a thin shell. */

const noop = () => {
  // no environment to observe
};

const canObserve = typeof window !== "undefined";

/** A canvas cannot inherit `currentColor`; resolve it off the computed style. */
export function resolveInk(canvas: HTMLCanvasElement, color: string): string {
  if (color !== "currentColor") {
    return color;
  }
  return getComputedStyle(canvas).color || "#000";
}

/**
 * Fires when the ambient theme could have changed: OS preference, or a
 * `class`/`data-theme` flip up the tree. Re-resolve `currentColor` on it.
 */
export function watchTheme(onChange: () => void): () => void {
  if (!canObserve) {
    return noop;
  }

  const query = window.matchMedia("(prefers-color-scheme: dark)");
  query.addEventListener("change", onChange);

  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, {
    attributeFilter: ["class", "data-theme", "style"],
    attributes: true,
    subtree: true,
  });

  return () => {
    query.removeEventListener("change", onChange);
    observer.disconnect();
  };
}

export function prefersReducedMotion(): boolean {
  if (!canObserve) {
    return false;
  }
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

export function watchReducedMotion(
  onChange: (reduced: boolean) => void
): () => void {
  if (!canObserve) {
    return noop;
  }
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  const handler = (event: MediaQueryListEvent) => onChange(event.matches);
  query.addEventListener("change", handler);
  return () => query.removeEventListener("change", handler);
}

/**
 * Whether the element is worth painting: on screen and in a visible tab. Off
 * either one, the indicator should cost nothing.
 */
export function watchPaintability(
  element: Element,
  onChange: (paintable: boolean) => void
): () => void {
  if (!canObserve) {
    return noop;
  }

  let onScreen = true;

  const emit = () =>
    onChange(onScreen && document.visibilityState !== "hidden");

  const observer =
    typeof IntersectionObserver === "undefined"
      ? null
      : new IntersectionObserver((entries) => {
          const [entry] = entries;
          if (entry) {
            onScreen = entry.isIntersecting;
            emit();
          }
        });

  observer?.observe(element);
  document.addEventListener("visibilitychange", emit);

  // Without an IntersectionObserver, assume on-screen and let visibility drive.
  if (!observer) {
    emit();
  }

  return () => {
    observer?.disconnect();
    document.removeEventListener("visibilitychange", emit);
  };
}

/**
 * Backing-store pixel ratio, capped. The floor is 2 even on a 1× display: small
 * marks are all edge, and supersampling a 20px canvas is 1600 cheap pixels.
 */
export function devicePixelRatioCapped(max = 3, min = 2): number {
  const ratio = typeof devicePixelRatio === "undefined" ? 1 : devicePixelRatio;
  return Math.min(max, Math.max(min, ratio || 1));
}
registry/ui/benday/presets.ts
import type { DotFrame, Preset, PresetName } from "./types";

const TAU = Math.PI * 2;
const fract = (v: number) => v - Math.floor(v);
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));

/** Signed wrapped difference in a 0..1 cyclic space, -0.5..0.5. */
const cyclicDelta = (v: number) => {
  const d = fract(v);
  return d > 0.5 ? d - 1 : d;
};

const gauss = (d: number, sigma: number) =>
  Math.exp(-(d * d) / (2 * sigma * sigma));

function smoothstep(edge0: number, edge1: number, value: number): number {
  const x = clamp01((value - edge0) / (edge1 - edge0));
  return x * x * (3 - 2 * x);
}

function hash2(x: number, y: number): number {
  const s = Math.sin(x * 127.1 + y * 311.7) * 43_758.5453;
  return s - Math.floor(s);
}

/** Smoothed 2D value noise, 0..1. */
function noise2(x: number, y: number): number {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  let fx = x - xi;
  let fy = y - yi;
  fx = fx * fx * (3 - 2 * fx);
  fy = fy * fy * (3 - 2 * fy);
  const a = hash2(xi, yi);
  const b = hash2(xi + 1, yi);
  const c = hash2(xi, yi + 1);
  const d = hash2(xi + 1, yi + 1);
  return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
}

/* Each preset writes into a reused `out` — it runs once per dot per frame, so
   allocating there would churn the heap at 60fps × 500 dots. */

/** A lit band sweeps the mark on the diagonal — the shimmer-text idiom, in dots. */
const shimmer: Preset = (c, t, out) => {
  const u = c.x * 0.72 + c.y * 0.28;
  const phase = cyclicDelta(u - t * 0.42);
  const g = gauss(phase, 0.11);
  out.a = 0.2 + 0.8 * g;
  out.s = 0.8 + 0.4 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Concentric rings pulse outward from the mark's center. */
const ripple: Preset = (c, t, out) => {
  const w = (Math.sin(t * 2.4 - c.r * 7) + 1) / 2;
  const g = w * w;
  out.a = 0.18 + 0.82 * g;
  out.s = 0.76 + 0.42 * g;
  out.dx = 0;
  out.dy = 0;
};

/** A wave travels along the shape's own thickness: outline first, core last. */
const contour: Preset = (c, t, out) => {
  const w = (Math.sin(t * 2.2 - c.d * 6.5) + 1) / 2;
  const g = w ** 1.6;
  out.a = 0.16 + 0.84 * g;
  out.s = 0.7 + 0.55 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Dots drift off the lattice on a noise field, then reconverge into the mark. */
const scatter: Preset = (c, t, out) => {
  const spread = 0.5 - 0.5 * Math.cos(t * 0.85);
  const amp = spread * 2.4;
  const nx = noise2(c.x * 3.1 + t * 0.22, c.y * 3.1) * 2 - 1;
  const ny = noise2(c.x * 3.1 + 17.3, c.y * 3.1 - t * 0.22) * 2 - 1;
  const jitter = (c.rand - 0.5) * 0.6 * spread;
  out.dx = nx * amp + jitter;
  out.dy = ny * amp - jitter;
  out.a = 1 - 0.5 * spread;
  out.s = 1 - 0.28 * spread;
};

/** A random subset of dots blinks at any moment. */
const flicker: Preset = (c, t, out) => {
  const phase = fract(t * 0.7 + c.rand);
  const g = phase < 0.3 ? Math.sin((phase / 0.3) * Math.PI) : 0;
  out.a = 0.14 + 0.86 * g;
  out.s = 0.82 + 0.34 * g;
  out.dx = 0;
  out.dy = 0;
};

/** The whole mark swells and settles, with a slight delay toward the edges. */
const breathe: Preset = (c, t, out) => {
  const g = (Math.sin(t * 1.5 - c.r * 1.1) + 1) / 2;
  out.a = 0.42 + 0.58 * g;
  out.s = 0.86 + 0.24 * g;
  out.dx = 0;
  out.dy = 0;
};

/** The mark twists around its center, outer dots lagging the inner ones. */
const swirl: Preset = (c, t, out) => {
  const rotation = 0.45 * Math.sin(t * 1.3 - c.r * 2.4);
  const cos = Math.cos(rotation);
  const sin = Math.sin(rotation);
  const rx = c.nx * cos - c.ny * sin;
  const ry = c.nx * sin + c.ny * cos;
  out.dx = (rx - c.nx) * (c.cols / 2);
  out.dy = (ry - c.ny) * (c.rows / 2);
  const swing = Math.abs(rotation) / 0.45;
  out.a = 0.55 + 0.45 * (1 - swing);
  out.s = 0.9 + 0.15 * (1 - swing);
};

/** A narrow vertical beam traverses the complete silhouette. */
const scan: Preset = (c, t, out) => {
  const phase = cyclicDelta(c.x - t * 0.34);
  const g = gauss(phase, 0.075);
  out.a = 0.2 + 0.8 * g;
  out.s = 0.76 + 0.42 * g;
  out.dx = 0;
  out.dy = 0;
};

/** One long signal follows the baked lattice in row-wise serpentine order. */
const cascade: Preset = (c, t, out) => {
  const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));
  const across = row % 2 === 0 ? c.x : 1 - c.x;
  const path = (row + across) / c.rows;
  const phase = cyclicDelta(path - t * 0.24);
  const g = gauss(phase, 0.055);
  out.a = 0.16 + 0.84 * g;
  out.s = 0.72 + 0.5 * g;
  out.dx = 0;
  out.dy = 0;
};

/** A soft energy point circles the center without rotating the logo itself. */
const orbit: Preset = (c, t, out) => {
  const angle = t * 1.25;
  const ox = Math.cos(angle) * 0.62;
  const oy = Math.sin(angle) * 0.62;
  const distance = Math.hypot(c.nx - ox, c.ny - oy);
  const g = gauss(distance, 0.3);
  out.a = 0.2 + 0.8 * g;
  out.s = 0.76 + 0.44 * g;
  out.dx = 0;
  out.dy = 0;
};

/** A bright head and tapered tail chase clockwise around the mark. */
const comet: Preset = (c, t, out) => {
  const angle = fract(c.angle / TAU);
  const behind = fract(t * 0.2 - angle);
  const trail = Math.exp(-behind * 7);
  const ring = 0.35 + 0.65 * smoothstep(0.12, 0.72, c.r);
  const g = trail * ring;
  out.a = 0.18 + 0.82 * g;
  out.s = 0.75 + 0.48 * g;
  out.dx = -c.ny * g * 0.26;
  out.dy = c.nx * g * 0.26;
};

/** A rotating search beam crosses the full logo with a soft angular wake. */
const radar: Preset = (c, t, out) => {
  const angle = fract(c.angle / TAU);
  const head = fract(t * 0.16);
  const delta = cyclicDelta(angle - head);
  const beam = gauss(delta, 0.045);
  const wake = delta < 0 ? Math.exp(delta * 8) * 0.42 : 0;
  const g = Math.max(beam, wake) * (0.72 + c.r * 0.28);
  out.a = 0.18 + 0.82 * g;
  out.s = 0.76 + 0.42 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Three curved blades rotate through the mark around a steady core. */
const pinwheel: Preset = (c, t, out) => {
  const wave = (Math.sin(c.angle * 3 - t * 2.1 - c.r * 3.4) + 1) / 2;
  const g = wave ** 2.2;
  const core = gauss(c.r, 0.18);
  out.a = 0.18 + 0.82 * Math.max(g, core);
  out.s = 0.76 + 0.4 * Math.max(g, core);
  out.dx = 0;
  out.dy = 0;
};

/** A soft traveling current bends the lattice without breaking the silhouette. */
const wave: Preset = (c, t, out) => {
  const phase = t * 1.7 - c.y * 6.2;
  const crest = (Math.sin(phase) + 1) / 2;
  const sway = Math.sin(phase) * (0.28 + c.r * 0.42);
  out.a = 0.46 + 0.54 * crest;
  out.s = 0.86 + 0.24 * crest;
  out.dx = sway;
  out.dy = Math.cos(phase - c.x * 1.4) * 0.08;
};

/** Independent column levels rise and fall like a restrained spectrum display. */
const equalizer: Preset = (c, t, out) => {
  const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));
  const phase = t * 1.85 + col * 0.73 + hash2(col, 4.2) * 1.4;
  const level = 0.2 + 0.75 * ((Math.sin(phase) + 1) / 2);
  const g = 1 - smoothstep(level - 0.08, level + 0.08, Math.abs(c.ny));
  out.a = 0.18 + 0.82 * g;
  out.s = 0.76 + 0.4 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Counter-moving diagonal bands cross and briefly brighten where they meet. */
const weave: Preset = (c, t, out) => {
  const a = gauss(cyclicDelta(c.x * 0.62 + c.y * 0.38 - t * 0.3), 0.08);
  const b = gauss(cyclicDelta(c.x * 0.62 - c.y * 0.38 + t * 0.24), 0.08);
  const g = clamp01(Math.max(a, b, Math.min(a, b) * 1.25));
  out.a = 0.18 + 0.82 * g;
  out.s = 0.76 + 0.44 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Emphasis hands off between north, east, south and west. */
const beacon: Preset = (c, t, out) => {
  let sector = 0;
  if (Math.abs(c.nx) > Math.abs(c.ny)) {
    sector = c.nx > 0 ? 0.25 : 0.75;
  } else if (c.ny > 0) {
    sector = 0.5;
  }
  const phase = cyclicDelta(sector - t * 0.28);
  const g = gauss(phase, 0.1) * (0.55 + c.r * 0.45);
  out.a = 0.2 + 0.8 * g;
  out.s = 0.78 + 0.4 * g;
  out.dx = 0;
  out.dy = 0;
};

/** Brief horizontal faults disturb a few rows, then snap cleanly home. */
const glitch: Preset = (c, t, out) => {
  const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));
  const cycle = Math.floor(t * 0.48 + 0.5);
  const burst = gauss(cyclicDelta(t * 0.48), 0.055);
  const slice = hash2(row, cycle);
  const active = slice > 0.58 ? burst : 0;
  out.a = 1 - active * 0.34;
  out.s = 1 - active * 0.12;
  out.dx = (slice - 0.5) * 3.2 * active;
  out.dy = 0;
};

/** Offset droplets descend each column with soft tails. */
const rain: Preset = (c, t, out) => {
  const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));
  const head = fract(t * 0.34 + hash2(col, 8.1));
  const delta = cyclicDelta(c.y - head);
  const trail = delta < 0 ? Math.exp(delta * 6) : Math.exp(-delta * 18);
  out.a = 0.16 + 0.84 * trail;
  out.s = 0.74 + 0.44 * trail;
  out.dx = 0;
  out.dy = trail * 0.18;
};

/** The field is pulled toward its center and released without losing its shape. */
const magnetic: Preset = (c, t, out) => {
  const pull = 0.5 - 0.5 * Math.cos(t * 1.15);
  const strength = pull * (0.35 + c.r * 0.65);
  out.dx = -c.nx * c.cols * 0.12 * strength;
  out.dy = -c.ny * c.rows * 0.12 * strength;
  out.a = 1 - pull * 0.28;
  out.s = 1 - pull * 0.2;
};

/** Dots assemble in a stable random order, hold, then dissolve to begin again. */
const resolve: Preset = (c, t, out) => {
  const progress = 0.5 - 0.5 * Math.cos(t * 1.08);
  const g = smoothstep(c.rand - 0.12, c.rand + 0.12, progress);
  out.a = 0.14 + 0.86 * g;
  out.s = 0.66 + 0.34 * g;
  out.dx = c.nx * (1 - g) * 0.38;
  out.dy = c.ny * (1 - g) * 0.38;
};

export type PresetFamily =
  | "Signature"
  | "Sweep"
  | "Orbit"
  | "Field"
  | "Transform";

export interface PresetDefinition {
  name: PresetName;
  label: string;
  description: string;
  family: PresetFamily;
  fn: Preset;
}

export const PRESET_FAMILIES: PresetFamily[] = [
  "Signature",
  "Sweep",
  "Orbit",
  "Field",
  "Transform",
];

export const PRESETS: Record<PresetName, PresetDefinition> = {
  beacon: {
    description: "Emphasis hands off between the four cardinal directions.",
    family: "Orbit",
    fn: beacon,
    label: "Beacon",
    name: "beacon",
  },
  breathe: {
    description: "The whole mark swells and settles with a soft edge delay.",
    family: "Signature",
    fn: breathe,
    label: "Breathe",
    name: "breathe",
  },
  cascade: {
    description: "One signal follows the lattice in serpentine order.",
    family: "Sweep",
    fn: cascade,
    label: "Cascade",
    name: "cascade",
  },
  comet: {
    description: "A bright head and tapered tail chase around the mark.",
    family: "Orbit",
    fn: comet,
    label: "Comet",
    name: "comet",
  },
  contour: {
    description: "A wave follows the mark’s thickness from outline to core.",
    family: "Signature",
    fn: contour,
    label: "Contour",
    name: "contour",
  },
  equalizer: {
    description: "Independent column levels rise and fall like a spectrum.",
    family: "Field",
    fn: equalizer,
    label: "Equalizer",
    name: "equalizer",
  },
  flicker: {
    description: "A stable random subset of dots blinks at any moment.",
    family: "Field",
    fn: flicker,
    label: "Flicker",
    name: "flicker",
  },
  glitch: {
    description: "Brief horizontal faults disturb a few rows, then clear.",
    family: "Transform",
    fn: glitch,
    label: "Glitch",
    name: "glitch",
  },
  magnetic: {
    description: "The field pulls toward its center and releases.",
    family: "Transform",
    fn: magnetic,
    label: "Magnetic",
    name: "magnetic",
  },
  orbit: {
    description: "A soft energy point circles the center of the mark.",
    family: "Orbit",
    fn: orbit,
    label: "Orbit",
    name: "orbit",
  },
  pinwheel: {
    description: "Three curved blades rotate around a steady core.",
    family: "Orbit",
    fn: pinwheel,
    label: "Pinwheel",
    name: "pinwheel",
  },
  radar: {
    description: "A rotating search beam crosses the logo with a soft wake.",
    family: "Orbit",
    fn: radar,
    label: "Radar",
    name: "radar",
  },
  rain: {
    description: "Offset droplets descend each column with soft tails.",
    family: "Sweep",
    fn: rain,
    label: "Rain",
    name: "rain",
  },
  resolve: {
    description: "Dots assemble in stable random order, then dissolve.",
    family: "Field",
    fn: resolve,
    label: "Resolve",
    name: "resolve",
  },
  ripple: {
    description: "Concentric rings pulse outward from the center.",
    family: "Signature",
    fn: ripple,
    label: "Ripple",
    name: "ripple",
  },
  scan: {
    description: "A narrow vertical beam traverses the silhouette.",
    family: "Sweep",
    fn: scan,
    label: "Scan",
    name: "scan",
  },
  scatter: {
    description: "Dots drift off the lattice, then reconverge into the mark.",
    family: "Transform",
    fn: scatter,
    label: "Scatter",
    name: "scatter",
  },
  shimmer: {
    description: "A lit band sweeps across the mark on the diagonal.",
    family: "Signature",
    fn: shimmer,
    label: "Shimmer",
    name: "shimmer",
  },
  swirl: {
    description: "The mark twists around its center, with outer dots lagging.",
    family: "Orbit",
    fn: swirl,
    label: "Swirl",
    name: "swirl",
  },
  wave: {
    description: "A soft traveling current bends the dot lattice.",
    family: "Field",
    fn: wave,
    label: "Wave",
    name: "wave",
  },
  weave: {
    description: "Counter-moving diagonal bands cross through the mark.",
    family: "Sweep",
    fn: weave,
    label: "Weave",
    name: "weave",
  },
};

/** Curated gallery order: approachable signatures before more expressive motion. */
export const PRESET_NAMES: PresetName[] = [
  "contour",
  "shimmer",
  "ripple",
  "breathe",
  "scan",
  "cascade",
  "weave",
  "rain",
  "swirl",
  "orbit",
  "comet",
  "radar",
  "pinwheel",
  "beacon",
  "flicker",
  "wave",
  "equalizer",
  "resolve",
  "scatter",
  "magnetic",
  "glitch",
];

/** Per-dot randomness that stays stable across frames. */
export function dotRandom(i: number): number {
  return hash2(i * 0.371, i * 0.917 + 3.14);
}

export function makeFrame(): DotFrame {
  return { a: 1, dx: 0, dy: 0, s: 1 };
}
registry/ui/benday/renderer.ts
import {
  devicePixelRatioCapped,
  prefersReducedMotion,
  resolveInk,
  watchPaintability,
  watchReducedMotion,
  watchTheme,
} from "./dom";
import { PRESETS, dotRandom } from "./presets";
import type {
  DotContext,
  DotFrame,
  DotMap,
  DotShape,
  Preset,
  Renderer,
  RendererOptions,
  ResolvedRendererOptions,
} from "./types";

export const DEFAULT_RENDERER_OPTIONS: ResolvedRendererOptions = {
  color: "currentColor",
  dotMap: null,
  dotScale: 0.62,
  fit: "square",
  glow: 0,
  padding: 0.06,
  paused: false,
  preset: "contour",
  reducedMotion: "auto",
  shape: "circle",
  size: 64,
  speed: 1,
  state: "thinking",
  weight: 0.5,
};

/** Light spring for the thinking ↔ crisp transition. Slightly under-damped. */
const STIFFNESS = 140;
const DAMPING = 20;
const MAX_TIMESTEP = 0.05;
/** Below this the lattice is subpixel haze. In device pixels: that is where dots resolve. */
const MIN_AUTO_CELL_DEVICE_PX = 2;

/** Dot diameter, in cells, whose area fills the cell — what full ink must reach to paint solid. */
const FULL_COVERAGE_DIAMETER = 2 / Math.sqrt(Math.PI);

/** Options whose change invalidates the cached per-dot contexts. */
const DERIVED_KEYS = ["dotMap", "fit", "padding", "size", "weight"] as const;
/** Options whose change invalidates the cached canvas geometry. */
const LAYOUT_KEYS = ["dotMap", "size", "fit", "padding", "dotScale"] as const;

/**
 * Paint a {@link DotMap} onto a canvas and animate it. One per canvas, updated
 * through {@link Renderer.update}: the clock lives here, so props never restart it.
 */
export function createRenderer(
  canvas: HTMLCanvasElement,
  initial: RendererOptions = {}
): Renderer {
  const context = canvas.getContext("2d");
  if (!context) {
    throw new Error("benday: could not acquire a 2D context");
  }
  const ctx = context;

  let opts: ResolvedRendererOptions = {
    ...DEFAULT_RENDERER_OPTIONS,
    ...strip(initial),
  };

  // Caches, rebuilt only when their inputs change.
  let dots: DotContext[] = [];
  let toneScales: number[] = [];
  let displayCols = 1;
  let displayRows = 1;
  let ink = "#000";
  let cssWidth = 0;
  let cssHeight = 0;
  let cell = 0;
  let originX = 0;
  let originY = 0;
  let baseRadius = 0;

  // Animation state, deliberately outliving update().
  let clock = 0;
  let settle = 1;
  let velocity = 0;
  let frameId = 0;
  let running = false;
  let lastFrame = 0;
  let paintable = true;
  let systemReducedMotion = prefersReducedMotion();

  const frame: DotFrame = { a: 1, dx: 0, dy: 0, s: 1 };

  function isReduced(): boolean {
    return opts.reducedMotion === "auto"
      ? systemReducedMotion
      : opts.reducedMotion;
  }

  function presetFn(): Preset {
    return typeof opts.preset === "function"
      ? opts.preset
      : PRESETS[opts.preset].fn;
  }

  function buildDerived(): void {
    const map = opts.dotMap;
    if (!map) {
      dots = [];
      toneScales = [];
      displayCols = 1;
      displayRows = 1;
      return;
    }

    const display = buildDisplayMap(map, opts);
    displayCols = display.cols;
    displayRows = display.rows;
    dots = display.dots.map((dot, i) => {
      const nx = dot.x * 2 - 1;
      const ny = dot.y * 2 - 1;
      return {
        angle: Math.atan2(ny, nx),
        cols: displayCols,
        d: dot.d,
        i,
        n: display.dots.length,
        nx,
        ny,
        r: Math.min(1, Math.hypot(nx, ny) / Math.SQRT2),
        rand: dotRandom(i),
        rows: displayRows,
        t: dot.t,
        v: dot.v,
        x: dot.x,
        y: dot.y,
      };
    });
    toneScales = display.dots.map((dot) =>
      toneScale(dot.v, dot.t, opts.weight)
    );
  }

  function buildLayout(): void {
    const map = opts.dotMap;
    const cols = map ? displayCols : 1;
    const rows = map ? displayRows : 1;

    cssWidth = opts.size;
    cssHeight =
      opts.fit === "natural" ? opts.size / safeAspect(map) : opts.size;

    const dpr = devicePixelRatioCapped();
    canvas.width = Math.round(cssWidth * dpr);
    canvas.height = Math.round(cssHeight * dpr);
    canvas.style.width = `${cssWidth}px`;
    canvas.style.height = `${cssHeight}px`;

    const padding = opticalPadding(opts.padding, Math.min(cssWidth, cssHeight));
    const availableWidth = cssWidth * (1 - padding * 2);
    const availableHeight = cssHeight * (1 - padding * 2);
    cell = Math.min(availableWidth / cols, availableHeight / rows);
    // A fine lattice lands every dot on a different fraction of a device pixel,
    // so each antialiases differently and the grid dissolves. Only small marks
    // need it, and only if the snapped lattice still fits the canvas.
    if (opticalFactor(Math.min(cssWidth, cssHeight)) > 0) {
      const devicePixel = 1 / dpr;
      const snapped = Math.max(
        devicePixel,
        Math.round(cell / devicePixel) * devicePixel
      );
      if (snapped * cols <= cssWidth && snapped * rows <= cssHeight) {
        cell = snapped;
      }
    }
    originX = snapTo((cssWidth - cell * cols) / 2, dpr);
    originY = snapTo((cssHeight - cell * rows) / 2, dpr);
    baseRadius = (cell * FULL_COVERAGE_DIAMETER * opts.dotScale) / 2;

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function buildInk(): void {
    ink = resolveInk(canvas, opts.color);
  }

  function paint(): void {
    ctx.clearRect(0, 0, cssWidth, cssHeight);
    if (dots.length === 0) {
      return;
    }

    ctx.fillStyle = ink;
    const optical = opticalFactor(Math.min(cssWidth, cssHeight));
    // Only `glow` blurs. Small marks used to get a halo here for optical
    // weight, but at 16px it came out wider than the cell and the lattice bled
    // into haze; `optical` buys that weight through radius and alpha instead.
    if (opts.glow > 0) {
      ctx.shadowColor = ink;
      ctx.shadowBlur = baseRadius * 4 * opts.glow;
    } else {
      ctx.shadowBlur = 0;
    }

    const run = presetFn();
    const cols = displayCols;
    const rows = displayRows;
    const motion = (1 - settle) * (1 - optical * 0.65);

    for (let i = 0; i < dots.length; i++) {
      const dot = dots[i] as DotContext;
      frame.s = 1;
      frame.a = 1;
      frame.dx = 0;
      frame.dy = 0;
      run(dot, clock, frame);

      // settle = 1 is the crisp logo; blend every channel toward it.
      const settledScale = frame.s + (1 - frame.s) * settle;
      const settledAlpha = frame.a + (1 - frame.a) * settle;
      const scale = settledScale + (1 - settledScale) * optical * 0.55;
      const alpha = settledAlpha + (1 - settledAlpha) * optical * 0.8;
      const radius =
        baseRadius * scale * (toneScales[i] as number) * shapeScale(opts.shape);
      if (radius <= 0.05) {
        continue;
      }
      const opacity = Math.min(1, Math.max(0, alpha));
      if (opacity <= 0.004) {
        continue;
      }

      ctx.globalAlpha = opacity;
      drawDot(
        ctx,
        opts.shape,
        originX + (dot.x * cols + frame.dx * motion) * cell,
        originY + (dot.y * rows + frame.dy * motion) * cell,
        radius
      );
    }

    ctx.globalAlpha = 1;
    ctx.shadowBlur = 0;
  }

  function settleTarget(): number {
    return opts.state === "thinking" ? 0 : 1;
  }

  function isSettled(): boolean {
    return settle === settleTarget() && velocity === 0;
  }

  function shouldAnimate(): boolean {
    if (isReduced() || opts.paused || !paintable) {
      return false;
    }
    return opts.state === "thinking" || !isSettled();
  }

  function step(now: number): void {
    const dt = lastFrame
      ? Math.min(MAX_TIMESTEP, (now - lastFrame) / 1000)
      : 1 / 60;
    lastFrame = now;
    clock += dt * opts.speed;

    const target = settleTarget();
    velocity += ((target - settle) * STIFFNESS - velocity * DAMPING) * dt;
    settle += velocity * dt;
    if (Math.abs(target - settle) < 0.001 && Math.abs(velocity) < 0.01) {
      settle = target;
      velocity = 0;
    }

    paint();

    if (running && shouldAnimate()) {
      frameId = requestAnimationFrame(step);
    } else {
      running = false;
    }
  }

  function start(): void {
    if (running) {
      return;
    }
    running = true;
    lastFrame = 0;
    frameId = requestAnimationFrame(step);
  }

  function stop(): void {
    running = false;
    cancelAnimationFrame(frameId);
  }

  function sync(): void {
    if (shouldAnimate()) {
      start();
    } else {
      stop();
    }
  }

  // Reduced motion means "show me the mark, not the animation".
  function applyReducedMotion(): void {
    if (isReduced()) {
      settle = 1;
      velocity = 0;
    }
  }

  const stopTheme = watchTheme(() => {
    buildInk();
    if (!running) {
      paint();
    }
  });

  const stopReducedMotion = watchReducedMotion((reduced) => {
    systemReducedMotion = reduced;
    applyReducedMotion();
    paint();
    sync();
  });

  const stopPaintability = watchPaintability(canvas, (next) => {
    paintable = next;
    sync();
  });

  buildDerived();
  buildLayout();
  buildInk();
  applyReducedMotion();
  paint();
  sync();

  return {
    destroy(): void {
      stop();
      stopTheme();
      stopReducedMotion();
      stopPaintability();
    },

    get height(): number {
      return cssHeight;
    },

    get options(): Readonly<ResolvedRendererOptions> {
      return opts;
    },

    update(next: RendererOptions): void {
      const previous = opts;
      opts = { ...opts, ...strip(next) };

      if (DERIVED_KEYS.some((key) => previous[key] !== opts[key])) {
        buildDerived();
      }
      if (LAYOUT_KEYS.some((key) => previous[key] !== opts[key])) {
        buildLayout();
      }
      if (previous.color !== opts.color) {
        buildInk();
      }
      applyReducedMotion();

      paint();
      sync();
    },
  };
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/** Round a CSS-pixel position onto the backing store's pixel grid. */
function snapTo(value: number, dpr: number): number {
  return Math.round(value * dpr) / dpr;
}

function clamp01(value: number): number {
  return clamp(value, 0, 1);
}

interface DisplayDot {
  d: number;
  t: number;
  v: number;
  x: number;
  y: number;
}

interface DisplayMap {
  cols: number;
  dots: DisplayDot[];
  rows: number;
}

/**
 * Downsample an over-dense map into a coarser regular lattice. Missing source
 * dots count as empty cells, so the aggregate retains the source's ink mass
 * instead of merely making every surviving dot larger.
 */
function buildDisplayMap(
  map: DotMap,
  opts: ResolvedRendererOptions
): DisplayMap {
  const sourceCols = Math.max(1, Math.round(map.cols));
  const sourceRows = Math.max(1, Math.round(map.rows));
  const source = { cols: sourceCols, dots: map.dots, rows: sourceRows };
  if (map.dots.length === 0) {
    return source;
  }

  const width = Math.max(1, opts.size);
  const height = opts.fit === "natural" ? width / safeAspect(map) : width;
  const padding = opticalPadding(opts.padding, Math.min(width, height));
  const cell = Math.min(
    (width * (1 - padding * 2)) / sourceCols,
    (height * (1 - padding * 2)) / sourceRows
  );
  const minCell = MIN_AUTO_CELL_DEVICE_PX / devicePixelRatioCapped();
  if (!Number.isFinite(cell) || cell >= minCell) {
    return source;
  }

  const scale = cell / minCell;
  const cols = Math.max(1, Math.floor(sourceCols * scale));
  const rows = Math.max(1, Math.floor(sourceRows * scale));
  if (cols === sourceCols && rows === sourceRows) {
    return source;
  }

  const bins = Array.from({ length: cols * rows }, () => ({
    depth: 0,
    ink: 0,
    tone: 0,
  }));
  // Split each source cell across the target cells it overlaps, by area. Giving
  // it whole to one bin doubles the ink in every nth bin, which is worst at the
  // near-1 ratios small marks land on — the lattice grew a beat of its own.
  for (const dot of map.dots) {
    const coverage = clamp01(dot.v);
    if (coverage <= 0) {
      continue;
    }
    const left = (clamp(dot.col, 0, sourceCols) * cols) / sourceCols;
    const right = (clamp(dot.col + 1, 0, sourceCols) * cols) / sourceCols;
    const top = (clamp(dot.row, 0, sourceRows) * rows) / sourceRows;
    const bottom = (clamp(dot.row + 1, 0, sourceRows) * rows) / sourceRows;

    for (
      let row = Math.floor(top);
      row < Math.min(rows, Math.ceil(bottom));
      row++
    ) {
      const overlapY = Math.min(bottom, row + 1) - Math.max(top, row);
      if (overlapY <= 0) {
        continue;
      }
      for (
        let col = Math.floor(left);
        col < Math.min(cols, Math.ceil(right));
        col++
      ) {
        const overlapX = Math.min(right, col + 1) - Math.max(left, col);
        if (overlapX <= 0) {
          continue;
        }
        const bin = bins[row * cols + col];
        if (!bin) {
          continue;
        }
        // Weights over a full target cell sum to 1, so ink is already its mean.
        const ink = coverage * overlapX * overlapY;
        bin.ink += ink;
        bin.depth += clamp01(dot.d) * ink;
        bin.tone += clamp01(dot.t) * ink;
      }
    }
  }

  const dots: DisplayDot[] = [];
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      const bin = bins[row * cols + col];
      if (!bin || bin.ink <= 0.004) {
        continue;
      }
      dots.push({
        d: bin.depth / bin.ink,
        t: bin.tone / bin.ink,
        v: clamp01(bin.ink),
        x: (col + 0.5) / cols,
        y: (row + 0.5) / rows,
      });
    }
  }

  return { cols, dots, rows };
}

/**
 * A halftone represents coverage through area: radius therefore follows the
 * square root of tone. Opacity remains available to the animation instead of
 * applying coverage twice (radius, then alpha), which used to erase fine ink.
 *
 * Tone is what the cell covers times how dark it is. The floors this used to
 * multiply in compounded: empty cells painted 0.169 and full ones 0.319, so
 * every logo arrived the same flat grey. Depth stays out — it is the animation
 * channel, and folding it in only pulls area off the artwork's own tones.
 */
function toneScale(
  coverage: number,
  sourceTone: number,
  weight: number
): number {
  const w = clamp01(weight);
  // Below 1, weight interpolates toward a uniform field rather than shifting a floor.
  const weighted = 1 - w + w * clamp01(coverage);
  return Math.sqrt(clamp01(weighted * clamp01(sourceTone)));
}

/** Small marks need optical weight and restrained motion to remain identifiable. */
function opticalFactor(size: number): number {
  return clamp01((32 - size) / 16);
}

function opticalPadding(padding: number, size: number): number {
  return clamp(padding, 0, 0.49) * (1 - opticalFactor(size) * 0.65);
}

/** Keep circle, square and diamond at equal painted area for the same tone. */
function shapeScale(shape: DotShape): number {
  if (shape === "square") {
    return Math.sqrt(Math.PI / 4);
  }
  if (shape === "diamond") {
    return Math.sqrt(Math.PI / 2);
  }
  return 1;
}

function safeAspect(map: DotMap | null): number {
  if (map && Number.isFinite(map.aspect) && map.aspect > 0) {
    return map.aspect;
  }
  return map ? map.cols / Math.max(1, map.rows) : 1;
}

function drawDot(
  ctx: CanvasRenderingContext2D,
  shape: DotShape,
  x: number,
  y: number,
  r: number
): void {
  if (shape === "circle") {
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
    return;
  }
  if (shape === "square") {
    ctx.fillRect(x - r, y - r, r * 2, r * 2);
    return;
  }
  ctx.beginPath();
  ctx.moveTo(x, y - r);
  ctx.lineTo(x + r, y);
  ctx.lineTo(x, y + r);
  ctx.lineTo(x - r, y);
  ctx.closePath();
  ctx.fill();
}

/** Drop explicit `undefined` so spreading options never clobbers a default. */
function strip(options: RendererOptions): RendererOptions {
  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(options)) {
    if (value !== undefined) {
      out[key] = value;
    }
  }
  return out as RendererOptions;
}
registry/ui/benday/types.ts
/** A single dot sampled out of the source image. */
export interface Dot {
  /** Grid column. */
  col: number;
  /** Grid row. */
  row: number;
  /** Cell-center x, normalized 0..1 across the trimmed content box. */
  x: number;
  /** Cell-center y, normalized 0..1 across the trimmed content box. */
  y: number;
  /** Ink coverage of the cell, 0..1 (post-gamma). Drives the dot's painted area. */
  v: number;
  /** Source tone, 0 for the weakest layer and 1 for the strongest layer. */
  t: number;
  /** Depth inside the shape, 0 at the outline → 1 at the thickest point (from the EDT). */
  d: number;
}

/**
 * A baked logo: dots plus the geometry to lay them out. All the renderer needs,
 * small and JSON-serializable, so it can be produced at build time.
 */
export interface DotMap {
  /** Grid columns across the trimmed content box. */
  cols: number;
  /** Grid rows across the trimmed content box. */
  rows: number;
  /** width / height of the trimmed content box. */
  aspect: number;
  /** The dots that cleared the coverage threshold. */
  dots: Dot[];
  /** How many grid cells were tested (cols * rows) — useful for tuning feedback. */
  cells: number;
  /** Which masking strategy actually ran. */
  maskMode: "alpha" | "luma";
}

export type MaskMode = "auto" | "alpha" | "luma";

export interface BakeOptions {
  /** Dots across the longest side of the trimmed logo. @default 24 */
  grid?: number;
  /** Cell coverage required to emit a dot, 0..1. @default 0.18 */
  threshold?: number;
  /** Gamma applied to coverage before thresholding. <1 boosts faint ink. @default 1 */
  gamma?: number;
  /** How to separate ink from background; `auto` picks alpha over luma. @default 'auto' */
  maskMode?: MaskMode;
  /** Treat the luminance mask as inverted (light ink on dark). Only used in luma mode. */
  invert?: boolean;
  /** Grow the mask by N working pixels — the hairline rescue knob. @default 0 */
  dilate?: number;
  /** Trim to the mask's bounding box before gridding. @default true */
  trim?: boolean;
  /** Resolution the mask is computed at, longest side. @default auto from grid */
  workingSize?: number;
}

export type ResolvedBakeOptions = Required<Omit<BakeOptions, "workingSize">> & {
  workingSize: number;
};

/** Per-dot input handed to an animation preset each frame. */
export interface DotContext {
  /** Index in the dot list. */
  i: number;
  /** Total dot count. */
  n: number;
  /** Normalized position, 0..1. */
  x: number;
  y: number;
  /** Normalized position relative to the mark's center, roughly -1..1. */
  nx: number;
  ny: number;
  /** Radial distance from center, normalized so the corner is ~1. */
  r: number;
  /** Angle from center, -PI..PI. */
  angle: number;
  /** Ink coverage, 0..1. */
  v: number;
  /** Source tone, 0..1. */
  t: number;
  /** Depth inside the shape, 0 at the outline → 1 at the core. */
  d: number;
  /** Stable pseudo-random value for this dot, 0..1. */
  rand: number;
  /** Grid dimensions, so offsets can be reasoned about in cell units. */
  cols: number;
  rows: number;
}

/** What a preset produces for one dot on one frame. */
export interface DotFrame {
  /** Radius multiplier. */
  s: number;
  /** Alpha, 0..1. */
  a: number;
  /** Offset in cell units (1 = one grid cell). */
  dx: number;
  dy: number;
}

export type Preset = (c: DotContext, t: number, out: DotFrame) => void;

export type PresetName =
  | "shimmer"
  | "ripple"
  | "contour"
  | "scatter"
  | "flicker"
  | "breathe"
  | "swirl"
  | "scan"
  | "cascade"
  | "orbit"
  | "comet"
  | "radar"
  | "pinwheel"
  | "wave"
  | "equalizer"
  | "weave"
  | "beacon"
  | "glitch"
  | "rain"
  | "magnetic"
  | "resolve";

export type BendayState = "idle" | "thinking" | "done";

export type DotShape = "circle" | "square" | "diamond";

export type Fit = "square" | "natural";

/** Everything the renderer needs to paint a frame. */
export interface RendererOptions {
  /** The baked map to draw. `null` paints nothing. */
  dotMap?: DotMap | null;
  /** Preset name, or your own per-dot function. @default 'contour' */
  preset?: PresetName | Preset;
  /** @default 'thinking' */
  state?: BendayState;
  /** Rendered size in CSS pixels. @default 64 */
  size?: number;
  /** `natural` sizes the canvas to the mark's aspect, which wordmarks need. @default 'square' */
  fit?: Fit;
  /** Animation speed multiplier. @default 1 */
  speed?: number;
  /** Dot color. `currentColor` resolves against the canvas. @default 'currentColor' */
  color?: string;
  /** Dot size against a full-coverage halftone dot; 1 lets solid ink paint solid. @default 0.62 */
  dotScale?: number;
  /** @default 'circle' */
  shape?: DotShape;
  /** Glow radius as a fraction of the dot size, 0 disables. @default 0 */
  glow?: number;
  /** Inset around the mark as a fraction of the box. @default 0.06 */
  padding?: number;
  /** How strongly ink coverage drives each dot's painted area, 0..1. @default 0.5 */
  weight?: number;
  /** Freeze on the current frame. @default false */
  paused?: boolean;
  /** `auto` follows prefers-reduced-motion live; a boolean pins it. @default 'auto' */
  reducedMotion?: boolean | "auto";
}

export type ResolvedRendererOptions = Required<
  Omit<RendererOptions, "dotMap">
> & {
  dotMap: DotMap | null;
};

/**
 * A live canvas painter. Update it rather than recreating it, so the clock and
 * the settle spring survive prop changes.
 */
export interface Renderer {
  /** Merge new options in and repaint. Unspecified keys keep their value. */
  update: (next: RendererOptions) => void;
  /** The currently applied options. */
  readonly options: Readonly<ResolvedRendererOptions>;
  /** Canvas CSS height for the current options. */
  readonly height: number;
  /** Tear down observers and cancel any pending frame. */
  destroy: () => void;
}
registry/ui/benday/use-dot-map.ts
"use client";

import { useEffect, useRef, useState } from "react";

import type { BakeSource } from "./bake";
import { bake, bakeCached } from "./bake";
import type { BakeOptions, DotMap } from "./types";

export interface UseDotMapResult {
  dotMap: DotMap | null;
  loading: boolean;
  error: Error | null;
  /** Milliseconds the last bake took — handy for tuning. */
  elapsed: number | null;
}

const IDLE: UseDotMapResult = {
  dotMap: null,
  elapsed: null,
  error: null,
  loading: false,
};

const LOADING: UseDotMapResult = { ...IDLE, loading: true };

/**
 * Bake a source into a {@link DotMap}, re-running on change. Strings hit the
 * module cache; Blobs and Files bake fresh, having no stable identity.
 */
export function useDotMap(
  source: BakeSource | null | undefined,
  options: BakeOptions = {}
): UseDotMapResult {
  const optionsKey = JSON.stringify(options);

  // A string plus its options determines the result, so the pair keys the
  // effect. Anything else falls back to reference identity.
  const signature =
    typeof source === "string" ? `${source}|${optionsKey}` : source;

  const optionsRef = useRef<BakeOptions>(options);
  useEffect(() => {
    optionsRef.current = options;
  });

  const [state, setState] = useState<UseDotMapResult>(source ? LOADING : IDLE);
  // Reset to a loading frame *during* render when the input changes, rather
  // than in an effect — an effect would paint one frame of stale results first.
  const [lastSignature, setLastSignature] = useState(signature);
  if (signature !== lastSignature) {
    setLastSignature(signature);
    setState(source ? LOADING : IDLE);
  }

  useEffect(() => {
    if (!source) {
      return;
    }

    let live = true;
    const started = performance.now();

    const run = async () => {
      try {
        const dotMap =
          typeof source === "string"
            ? await bakeCached(source, optionsRef.current)
            : await bake(source, optionsRef.current);
        if (live) {
          setState({
            dotMap,
            elapsed: performance.now() - started,
            error: null,
            loading: false,
          });
        }
      } catch (error) {
        if (live) {
          setState({
            dotMap: null,
            elapsed: null,
            error: error as Error,
            loading: false,
          });
        }
      }
    };

    run();

    return () => {
      live = false;
    };
  }, [signature, source]);

  return state;
}