{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "benday",
  "title": "Benday",
  "author": "Kacem Mathlouthi",
  "description": "Turn any logo into a customizable animated Ben-Day dot field.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ui/benday.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties, CanvasHTMLAttributes } from \"react\";\nimport { useEffect, useRef } from \"react\";\n\nimport type { BakeSource } from \"./benday/bake\";\nimport { createRenderer } from \"./benday/renderer\";\nimport type {\n  BakeOptions,\n  BendayState,\n  DotMap,\n  Renderer,\n  RendererOptions,\n} from \"./benday/types\";\nimport { useDotMap } from \"./benday/use-dot-map\";\n\nexport interface BendayProps\n  extends\n    Omit<\n      CanvasHTMLAttributes<HTMLCanvasElement>,\n      \"color\" | \"height\" | \"style\" | \"width\"\n    >,\n    Omit<RendererOptions, \"dotMap\"> {\n  /** Image to bake at runtime — URL, data URI, File/Blob, or a loaded image. */\n  src?: BakeSource;\n  /** A pre-baked dot map. Takes precedence over `src`; this is the build-time path. */\n  dotMap?: DotMap;\n  /** Options for the runtime bake. Ignored when `dotMap` is supplied. */\n  bake?: BakeOptions;\n  style?: CSSProperties;\n}\n\nconst STATE_LABEL: Record<BendayState, string> = {\n  done: \"Done\",\n  idle: \"Idle\",\n  thinking: \"Thinking…\",\n};\n\n/**\n * A canvas indicator built from your logo. This owns only the element, the bake\n * and the props; `renderer.update()` takes changes without restarting anything.\n */\nexport function Benday({\n  src,\n  dotMap: dotMapProp,\n  bake: bakeOptions,\n  preset = \"contour\",\n  state = \"thinking\",\n  size = 64,\n  fit = \"square\",\n  speed = 1,\n  color = \"currentColor\",\n  dotScale = 0.62,\n  shape = \"circle\",\n  glow = 0,\n  padding = 0.06,\n  weight = 0.5,\n  paused = false,\n  reducedMotion = \"auto\",\n  style,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: BendayProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const rendererRef = useRef<Renderer | null>(null);\n\n  // Only bake internally when no map was handed in.\n  const baked = useDotMap(dotMapProp ? null : src, bakeOptions);\n  const dotMap = dotMapProp ?? baked.dotMap;\n\n  const options: RendererOptions = {\n    color,\n    dotMap,\n    dotScale,\n    fit,\n    glow,\n    padding,\n    paused,\n    preset,\n    reducedMotion,\n    shape,\n    size,\n    speed,\n    state,\n    weight,\n  };\n\n  // Declared before the mount effect so it has already run when the renderer is\n  // created — writing a ref during render is what React Compiler rules out.\n  const optionsRef = useRef(options);\n  useEffect(() => {\n    optionsRef.current = options;\n  });\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const renderer = createRenderer(canvas, optionsRef.current);\n    rendererRef.current = renderer;\n    return () => {\n      renderer.destroy();\n      rendererRef.current = null;\n    };\n  }, []);\n\n  useEffect(() => {\n    rendererRef.current?.update({\n      color,\n      dotMap,\n      dotScale,\n      fit,\n      glow,\n      padding,\n      paused,\n      preset,\n      reducedMotion,\n      shape,\n      size,\n      speed,\n      state,\n      weight,\n    });\n  }, [\n    color,\n    dotMap,\n    dotScale,\n    fit,\n    glow,\n    padding,\n    paused,\n    preset,\n    reducedMotion,\n    shape,\n    size,\n    speed,\n    state,\n    weight,\n  ]);\n\n  // Reserve the box before the renderer sizes the canvas, so nothing shifts.\n  const height = fit === \"natural\" && dotMap ? size / safeAspect(dotMap) : size;\n\n  return (\n    <canvas\n      aria-label={ariaLabel ?? STATE_LABEL[state]}\n      ref={canvasRef}\n      role=\"img\"\n      style={{ display: \"block\", height, width: size, ...style }}\n      {...rest}\n    />\n  );\n}\n\nexport {\n  DEFAULT_BAKE,\n  bake,\n  bakeCached,\n  bakeKey,\n  clearBakeCache,\n  resolveBakeOptions,\n} from \"./benday/bake\";\nexport type { BakeSource } from \"./benday/bake\";\nexport { prefersReducedMotion } from \"./benday/dom\";\nexport {\n  PRESET_FAMILIES,\n  PRESET_NAMES,\n  PRESETS,\n  dotRandom,\n  makeFrame,\n} from \"./benday/presets\";\nexport type { PresetDefinition, PresetFamily } from \"./benday/presets\";\nexport { DEFAULT_RENDERER_OPTIONS, createRenderer } from \"./benday/renderer\";\nexport type {\n  BakeOptions,\n  BendayState,\n  Dot,\n  DotContext,\n  DotFrame,\n  DotMap,\n  DotShape,\n  Fit,\n  MaskMode,\n  Preset,\n  PresetName,\n  Renderer,\n  RendererOptions,\n  ResolvedBakeOptions,\n  ResolvedRendererOptions,\n} from \"./benday/types\";\nexport { useDotMap } from \"./benday/use-dot-map\";\nexport type { UseDotMapResult } from \"./benday/use-dot-map\";\n\nfunction safeAspect(dotMap: DotMap): number {\n  return Number.isFinite(dotMap.aspect) && dotMap.aspect > 0\n    ? dotMap.aspect\n    : dotMap.cols / Math.max(1, dotMap.rows);\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday.tsx"
    },
    {
      "path": "registry/ui/benday/bake.ts",
      "content": "import type {\n  BakeOptions,\n  Dot,\n  DotMap,\n  MaskMode,\n  ResolvedBakeOptions,\n} from \"./types\";\n\nexport const DEFAULT_BAKE: Omit<ResolvedBakeOptions, \"workingSize\"> = {\n  dilate: 0,\n  gamma: 1,\n  grid: 24,\n  invert: false,\n  maskMode: \"auto\",\n  threshold: 0.18,\n  trim: true,\n};\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\nconst clamp01 = (v: number) => clamp(v, 0, 1);\n\nfunction smoothstep(edge0: number, edge1: number, value: number): number {\n  const x = clamp01((value - edge0) / (edge1 - edge0));\n  return x * x * (3 - 2 * x);\n}\n\n/** Luminance spread, in levels, over which layers go from flat to fully separated. */\nconst TONE_SPREAD_MIN = 16;\nconst TONE_SPREAD_FULL = 200;\n\nexport function resolveBakeOptions(\n  options: BakeOptions = {}\n): ResolvedBakeOptions {\n  const merged = { ...DEFAULT_BAKE, ...stripUndefined(options) };\n  const workingSize =\n    options.workingSize ?? clamp(Math.round(merged.grid * 16), 192, 768);\n  return { ...merged, workingSize };\n}\n\nfunction stripUndefined<T extends object>(o: T): Partial<T> {\n  const out: Partial<T> = {};\n  for (const k of Object.keys(o) as (keyof T)[]) {\n    if (o[k] !== undefined) {\n      out[k] = o[k];\n    }\n  }\n  return out;\n}\n\nexport type BakeSource = string | Blob | HTMLImageElement | ImageBitmap;\n\nfunction loadImage(src: string): Promise<HTMLImageElement> {\n  return new Promise((resolve, reject) => {\n    const img = new Image();\n    img.crossOrigin = \"anonymous\";\n    img.addEventListener(\"load\", () => resolve(img), { once: true });\n    img.addEventListener(\n      \"error\",\n      () => reject(new Error(`benday: failed to load image \"${src}\"`)),\n      { once: true }\n    );\n    img.src = src;\n  });\n}\n\n/**\n * Rasterize at `workingSize` (longest side). SVGs go straight to the target\n * size — vectors rasterize cleanly at any scale, so no intermediate pass.\n */\nasync function rasterize(\n  source: BakeSource,\n  workingSize: number\n): Promise<{ data: ImageData; width: number; height: number }> {\n  let el: CanvasImageSource;\n  let iw = 0;\n  let ih = 0;\n  let revoke: string | undefined;\n\n  try {\n    if (typeof source === \"string\") {\n      const img = await loadImage(source);\n      el = img;\n      iw = img.naturalWidth || img.width;\n      ih = img.naturalHeight || img.height;\n    } else if (\n      typeof ImageBitmap !== \"undefined\" &&\n      source instanceof ImageBitmap\n    ) {\n      el = source;\n      iw = source.width;\n      ih = source.height;\n    } else if (source instanceof Blob) {\n      revoke = URL.createObjectURL(source);\n      const img = await loadImage(revoke);\n      el = img;\n      iw = img.naturalWidth || img.width;\n      ih = img.naturalHeight || img.height;\n    } else {\n      const img = source as HTMLImageElement;\n      el = img;\n      iw = img.naturalWidth || img.width;\n      ih = img.naturalHeight || img.height;\n    }\n\n    // SVGs with only a viewBox report 0×0 in some browsers.\n    if (!iw || !ih) {\n      iw = 1024;\n      ih = 1024;\n    }\n\n    const scale = workingSize / Math.max(iw, ih);\n    const width = Math.max(1, Math.round(iw * scale));\n    const height = Math.max(1, Math.round(ih * scale));\n\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = width;\n    canvas.height = height;\n    const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) {\n      throw new Error(\"benday: could not acquire a 2D context\");\n    }\n    ctx.imageSmoothingEnabled = true;\n    ctx.imageSmoothingQuality = \"high\";\n    ctx.drawImage(el, 0, 0, width, height);\n\n    let data: ImageData;\n    try {\n      data = ctx.getImageData(0, 0, width, height);\n    } catch {\n      throw new Error(\n        \"benday: the image tainted the canvas (cross-origin without CORS headers)\"\n      );\n    }\n    return { data, height, width };\n  } finally {\n    if (revoke) {\n      URL.revokeObjectURL(revoke);\n    }\n  }\n}\n\nconst luma = (r: number, g: number, b: number) =>\n  (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\nfunction hasAlpha(px: Uint8ClampedArray): boolean {\n  // A handful of soft edge pixels is enough; a fully opaque photo has none.\n  let soft = 0;\n  for (let i = 3; i < px.length; i += 4) {\n    if (px[i] < 250) {\n      soft++;\n      if (soft > 8) {\n        return true;\n      }\n    }\n  }\n  return false;\n}\n\n/**\n * Ink coverage per pixel, 0 = background, 1 = solid ink. Luma mode reads the\n * corners for a background level, then measures each pixel's departure.\n */\nfunction buildCoverage(\n  img: ImageData,\n  width: number,\n  height: number,\n  mode: \"alpha\" | \"luma\",\n  invert: boolean\n): Float32Array {\n  const px = img.data;\n  const cov = new Float32Array(width * height);\n\n  if (mode === \"alpha\") {\n    for (let i = 0, p = 0; p < cov.length; i += 4, p++) {\n      cov[p] = px[i + 3] / 255;\n    }\n    return cov;\n  }\n\n  const corners = [\n    0,\n    (width - 1) * 4,\n    (height - 1) * width * 4,\n    ((height - 1) * width + width - 1) * 4,\n  ];\n  const cornerLuma = corners\n    .map((i) => luma(px[i], px[i + 1], px[i + 2]))\n    .toSorted((a, b) => a - b);\n  let bg = (cornerLuma[1] + cornerLuma[2]) / 2; // median of four\n  let bgIsLight = bg > 0.5;\n  if (invert) {\n    bgIsLight = !bgIsLight;\n  }\n  // Guard against a background that sits mid-grey, which would leave no headroom.\n  const span = Math.max(0.12, bgIsLight ? bg : 1 - bg);\n  if (!bgIsLight) {\n    bg = Math.min(bg, 1 - span);\n  }\n\n  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {\n    const l = luma(px[i], px[i + 1], px[i + 2]);\n    const ink = bgIsLight ? bg - l : l - bg;\n    // Transparent regions in a luma-mode image still read as background.\n    cov[p] = clamp01(ink / span) * (px[i + 3] / 255);\n  }\n  return cov;\n}\n\n/**\n * Preserve visible layers independently from silhouette coverage. Alpha tells\n * us where artwork exists, but not whether one opaque region is lighter than\n * another. A robust visible-pixel range turns that source contrast into a\n * separate 0..1 tone channel. Uniform artwork stays at full tone.\n */\nfunction buildTone(img: ImageData, cov: Float32Array): Float32Array {\n  const px = img.data;\n  const histogram = new Float64Array(256);\n  let total = 0;\n  let luminanceSum = 0;\n\n  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {\n    const coverage = cov[p];\n    if (coverage <= 0.02) {\n      continue;\n    }\n    const level = Math.round(luma(px[i], px[i + 1], px[i + 2]) * 255);\n    histogram[level] += coverage;\n    luminanceSum += level * coverage;\n    total += coverage;\n  }\n\n  const tone = new Float32Array(cov.length);\n  if (total === 0) {\n    return tone;\n  }\n\n  const low = histogramPercentile(histogram, total, 0.08);\n  const high = histogramPercentile(histogram, total, 0.92);\n  const span = high - low;\n\n  // Stretching the source's own range to a full 0..1 manufactures contrast: two\n  // brand colours a few levels apart came out one at full strength and one on\n  // the floor. Apply the range in proportion to the separation that is there.\n  const separation = smoothstep(TONE_SPREAD_MIN, TONE_SPREAD_FULL, span);\n  if (separation <= 0) {\n    for (let p = 0; p < cov.length; p++) {\n      tone[p] = cov[p] > 0.02 ? 1 : 0;\n    }\n    return tone;\n  }\n\n  // White artwork on transparency is as common as black artwork. Let the\n  // dominant half of the source decide which end of its range is strongest.\n  const lightIsStrong = luminanceSum / total > 140;\n  for (let i = 0, p = 0; p < cov.length; i += 4, p++) {\n    if (cov[p] <= 0.02) {\n      continue;\n    }\n    const level = luma(px[i], px[i + 1], px[i + 2]) * 255;\n    const normalized = clamp01(\n      lightIsStrong ? (level - low) / span : (high - level) / span\n    );\n    tone[p] = 1 - separation * (1 - normalized);\n  }\n  return tone;\n}\n\nfunction histogramPercentile(\n  histogram: Float64Array,\n  total: number,\n  percentile: number\n): number {\n  const target = total * percentile;\n  let seen = 0;\n  for (let i = 0; i < histogram.length; i++) {\n    seen += histogram[i];\n    if (seen >= target) {\n      return i;\n    }\n  }\n  return histogram.length - 1;\n}\n\n/** Separable box dilation — grows the mask to rescue hairline strokes. */\nfunction dilateCoverage(\n  cov: Float32Array,\n  width: number,\n  height: number,\n  r: number\n): Float32Array {\n  if (r <= 0) {\n    return cov;\n  }\n  const radius = Math.round(r);\n  const tmp = new Float32Array(cov.length);\n  const out = new Float32Array(cov.length);\n\n  for (let y = 0; y < height; y++) {\n    const row = y * width;\n    for (let x = 0; x < width; x++) {\n      let m = 0;\n      const lo = Math.max(0, x - radius);\n      const hi = Math.min(width - 1, x + radius);\n      for (let k = lo; k <= hi; k++) {\n        if (cov[row + k] > m) {\n          m = cov[row + k];\n        }\n      }\n      tmp[row + x] = m;\n    }\n  }\n  for (let x = 0; x < width; x++) {\n    for (let y = 0; y < height; y++) {\n      let m = 0;\n      const lo = Math.max(0, y - radius);\n      const hi = Math.min(height - 1, y + radius);\n      for (let k = lo; k <= hi; k++) {\n        const v = tmp[k * width + x];\n        if (v > m) {\n          m = v;\n        }\n      }\n      out[y * width + x] = m;\n    }\n  }\n  return out;\n}\n\nconst INF = 1e20;\n\n/** 1D squared distance transform of a sampled function, in place-ish. */\nfunction edt1d(\n  f: Float64Array,\n  d: Float64Array,\n  v: Int32Array,\n  z: Float64Array,\n  n: number\n): void {\n  let k = 0;\n  v[0] = 0;\n  z[0] = -INF;\n  z[1] = INF;\n  for (let q = 1; q < n; q++) {\n    let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n    while (s <= z[k]) {\n      k--;\n      s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n    }\n    k++;\n    v[k] = q;\n    z[k] = s;\n    z[k + 1] = INF;\n  }\n  k = 0;\n  for (let q = 0; q < n; q++) {\n    while (z[k + 1] < q) {\n      k++;\n    }\n    const dq = q - v[k];\n    d[q] = dq * dq + f[v[k]];\n  }\n}\n\n/**\n * Exact distance from each inside pixel to the nearest outside one, after\n * Felzenszwalb & Huttenlocher: O(n), and true distance a ripple can order by.\n */\nfunction distanceTransform(\n  mask: Uint8Array,\n  width: number,\n  height: number\n): { dist: Float32Array; max: number } {\n  const grid = new Float64Array(width * height);\n  for (let i = 0; i < grid.length; i++) {\n    grid[i] = mask[i] ? INF : 0;\n  }\n\n  const n = Math.max(width, height);\n  const f = new Float64Array(n);\n  const d = new Float64Array(n);\n  const v = new Int32Array(n);\n  const z = new Float64Array(n + 1);\n\n  for (let y = 0; y < height; y++) {\n    const row = y * width;\n    for (let x = 0; x < width; x++) {\n      f[x] = grid[row + x];\n    }\n    edt1d(f, d, v, z, width);\n    for (let x = 0; x < width; x++) {\n      grid[row + x] = d[x];\n    }\n  }\n  for (let x = 0; x < width; x++) {\n    for (let y = 0; y < height; y++) {\n      f[y] = grid[y * width + x];\n    }\n    edt1d(f, d, v, z, height);\n    for (let y = 0; y < height; y++) {\n      grid[y * width + x] = d[y];\n    }\n  }\n\n  const dist = new Float32Array(width * height);\n  let max = 0;\n  for (let i = 0; i < dist.length; i++) {\n    const val = Math.sqrt(grid[i]);\n    dist[i] = val;\n    if (val > max) {\n      max = val;\n    }\n  }\n  return { dist, max };\n}\n\ninterface ContentBounds {\n  minX: number;\n  minY: number;\n  boxW: number;\n  boxH: number;\n}\n\n/** Which masking strategy to run, resolving `auto` against the pixels. */\nfunction resolveMaskMode(\n  mode: MaskMode,\n  pixels: Uint8ClampedArray\n): \"alpha\" | \"luma\" {\n  if (mode !== \"auto\") {\n    return mode;\n  }\n  return hasAlpha(pixels) ? \"alpha\" : \"luma\";\n}\n\n/** Bounding box of the ink; the full frame if trimming is off or it is empty. */\nfunction findContentBounds(\n  cov: Float32Array,\n  width: number,\n  height: number,\n  trim: boolean\n): ContentBounds {\n  let minX = width;\n  let minY = height;\n  let maxX = -1;\n  let maxY = -1;\n\n  if (trim) {\n    for (let y = 0; y < height; y++) {\n      const row = y * width;\n      for (let x = 0; x < width; x++) {\n        if (cov[row + x] > 0.06) {\n          minX = Math.min(minX, x);\n          maxX = Math.max(maxX, x);\n          minY = Math.min(minY, y);\n          maxY = Math.max(maxY, y);\n        }\n      }\n    }\n  }\n\n  if (maxX < minX || maxY < minY) {\n    return { boxH: height, boxW: width, minX: 0, minY: 0 };\n  }\n  return {\n    boxH: maxY - minY + 1,\n    boxW: maxX - minX + 1,\n    minX,\n    minY,\n  };\n}\n\n/** Mean coverage and coverage-weighted depth per cell. */\nfunction sampleDots(\n  cov: Float32Array,\n  tone: Float32Array,\n  dist: Float32Array,\n  width: number,\n  height: number,\n  bounds: ContentBounds,\n  cols: number,\n  rows: number,\n  threshold: number\n): Dot[] {\n  const cellW = bounds.boxW / cols;\n  const cellH = bounds.boxH / rows;\n  const dots: Dot[] = [];\n\n  for (let row = 0; row < rows; row++) {\n    const y0 = Math.floor(bounds.minY + row * cellH);\n    const y1 = Math.max(y0 + 1, Math.floor(bounds.minY + (row + 1) * cellH));\n\n    for (let col = 0; col < cols; col++) {\n      const x0 = Math.floor(bounds.minX + col * cellW);\n      const x1 = Math.max(x0 + 1, Math.floor(bounds.minX + (col + 1) * cellW));\n\n      let sum = 0;\n      let count = 0;\n      let weightedDepth = 0;\n      let weightedTone = 0;\n\n      for (let y = y0; y < y1 && y < height; y++) {\n        const r = y * width;\n        for (let x = x0; x < x1 && x < width; x++) {\n          sum += cov[r + x];\n          weightedDepth += dist[r + x] * cov[r + x];\n          weightedTone += tone[r + x] * cov[r + x];\n          count++;\n        }\n      }\n\n      if (count === 0) {\n        continue;\n      }\n      const v = sum / count;\n      if (v < threshold) {\n        continue;\n      }\n\n      dots.push({\n        col,\n        d: sum > 0 ? weightedDepth / sum : 0,\n        row,\n        t: sum > 0 ? weightedTone / sum : 1,\n        v: clamp01(v),\n        x: (col + 0.5) / cols,\n        y: (row + 0.5) / rows,\n      });\n    }\n  }\n\n  return dots;\n}\n\n/**\n * Normalize against the deepest sampled dot, not the deepest pixel: a hairline\n * mark would otherwise flatten `d` to zero and leave depth presets inert.\n */\nfunction normalizeDepth(dots: Dot[]): void {\n  let maxDepth = 0;\n  for (const dot of dots) {\n    maxDepth = Math.max(maxDepth, dot.d);\n  }\n  for (const dot of dots) {\n    dot.d = maxDepth > 0 ? clamp01(dot.d / maxDepth) : 0;\n  }\n}\n\n/**\n * Turn an image into a {@link DotMap}. Browser-only: it goes via a canvas. Run\n * it at build time and ship the JSON, or at runtime for any-logo drop-in.\n */\nexport async function bake(\n  source: BakeSource,\n  options: BakeOptions = {}\n): Promise<DotMap> {\n  const opts = resolveBakeOptions(options);\n  const { data, width, height } = await rasterize(source, opts.workingSize);\n  const mode = resolveMaskMode(opts.maskMode, data.data);\n\n  let cov = buildCoverage(data, width, height, mode, opts.invert);\n  const tone = buildTone(data, cov);\n  if (opts.gamma !== 1) {\n    const g = Math.max(0.05, opts.gamma);\n    for (let i = 0; i < cov.length; i++) {\n      cov[i] **= g;\n    }\n  }\n  cov = dilateCoverage(cov, width, height, opts.dilate);\n\n  // Binary mask for the distance transform. A low fixed cut keeps antialiased\n  // edges inside the shape so the EDT measures the stroke, not the core.\n  const mask = new Uint8Array(cov.length);\n  for (let i = 0; i < cov.length; i++) {\n    mask[i] = cov[i] > 0.5 ? 1 : 0;\n  }\n\n  const bounds = findContentBounds(cov, width, height, opts.trim);\n  const { dist } = distanceTransform(mask, width, height);\n\n  // Grid the content box so the longest side gets `grid` cells.\n  const longest = Math.max(bounds.boxW, bounds.boxH);\n  const cols = Math.max(1, Math.round((bounds.boxW / longest) * opts.grid));\n  const rows = Math.max(1, Math.round((bounds.boxH / longest) * opts.grid));\n\n  const dots = sampleDots(\n    cov,\n    tone,\n    dist,\n    width,\n    height,\n    bounds,\n    cols,\n    rows,\n    opts.threshold\n  );\n  normalizeDepth(dots);\n\n  return {\n    aspect: bounds.boxW / bounds.boxH,\n    cells: cols * rows,\n    cols,\n    dots,\n    maskMode: mode,\n    rows,\n  };\n}\n\nconst cache = new Map<string, Promise<DotMap>>();\n\n/** Cache key for a (source, options) pair. Only string sources are cacheable. */\nexport function bakeKey(src: string, options: BakeOptions = {}): string {\n  const o = resolveBakeOptions(options);\n  return [\n    src,\n    o.grid,\n    o.threshold,\n    o.gamma,\n    o.maskMode,\n    o.invert ? 1 : 0,\n    o.dilate,\n    o.trim ? 1 : 0,\n    o.workingSize,\n  ].join(\"|\");\n}\n\n/** A failed bake must not stay cached, or the error is permanent. */\nasync function bakeAndForgetOnFailure(\n  key: string,\n  src: string,\n  options: BakeOptions\n): Promise<DotMap> {\n  try {\n    return await bake(src, options);\n  } catch (error) {\n    cache.delete(key);\n    throw error;\n  }\n}\n\n/** {@link bake}, memoized per (url, options). */\nexport function bakeCached(\n  src: string,\n  options: BakeOptions = {}\n): Promise<DotMap> {\n  const key = bakeKey(src, options);\n  let hit = cache.get(key);\n  if (!hit) {\n    hit = bakeAndForgetOnFailure(key, src, options);\n    cache.set(key, hit);\n  }\n  return hit;\n}\n\nexport function clearBakeCache(): void {\n  cache.clear();\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/bake.ts"
    },
    {
      "path": "registry/ui/benday/dom.ts",
      "content": "/** Framework-agnostic DOM plumbing; what keeps the React wrapper a thin shell. */\n\nconst noop = () => {\n  // no environment to observe\n};\n\nconst canObserve = typeof window !== \"undefined\";\n\n/** A canvas cannot inherit `currentColor`; resolve it off the computed style. */\nexport function resolveInk(canvas: HTMLCanvasElement, color: string): string {\n  if (color !== \"currentColor\") {\n    return color;\n  }\n  return getComputedStyle(canvas).color || \"#000\";\n}\n\n/**\n * Fires when the ambient theme could have changed: OS preference, or a\n * `class`/`data-theme` flip up the tree. Re-resolve `currentColor` on it.\n */\nexport function watchTheme(onChange: () => void): () => void {\n  if (!canObserve) {\n    return noop;\n  }\n\n  const query = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  query.addEventListener(\"change\", onChange);\n\n  const observer = new MutationObserver(onChange);\n  observer.observe(document.documentElement, {\n    attributeFilter: [\"class\", \"data-theme\", \"style\"],\n    attributes: true,\n    subtree: true,\n  });\n\n  return () => {\n    query.removeEventListener(\"change\", onChange);\n    observer.disconnect();\n  };\n}\n\nexport function prefersReducedMotion(): boolean {\n  if (!canObserve) {\n    return false;\n  }\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport function watchReducedMotion(\n  onChange: (reduced: boolean) => void\n): () => void {\n  if (!canObserve) {\n    return noop;\n  }\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  const handler = (event: MediaQueryListEvent) => onChange(event.matches);\n  query.addEventListener(\"change\", handler);\n  return () => query.removeEventListener(\"change\", handler);\n}\n\n/**\n * Whether the element is worth painting: on screen and in a visible tab. Off\n * either one, the indicator should cost nothing.\n */\nexport function watchPaintability(\n  element: Element,\n  onChange: (paintable: boolean) => void\n): () => void {\n  if (!canObserve) {\n    return noop;\n  }\n\n  let onScreen = true;\n\n  const emit = () =>\n    onChange(onScreen && document.visibilityState !== \"hidden\");\n\n  const observer =\n    typeof IntersectionObserver === \"undefined\"\n      ? null\n      : new IntersectionObserver((entries) => {\n          const [entry] = entries;\n          if (entry) {\n            onScreen = entry.isIntersecting;\n            emit();\n          }\n        });\n\n  observer?.observe(element);\n  document.addEventListener(\"visibilitychange\", emit);\n\n  // Without an IntersectionObserver, assume on-screen and let visibility drive.\n  if (!observer) {\n    emit();\n  }\n\n  return () => {\n    observer?.disconnect();\n    document.removeEventListener(\"visibilitychange\", emit);\n  };\n}\n\n/**\n * Backing-store pixel ratio, capped. The floor is 2 even on a 1× display: small\n * marks are all edge, and supersampling a 20px canvas is 1600 cheap pixels.\n */\nexport function devicePixelRatioCapped(max = 3, min = 2): number {\n  const ratio = typeof devicePixelRatio === \"undefined\" ? 1 : devicePixelRatio;\n  return Math.min(max, Math.max(min, ratio || 1));\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/dom.ts"
    },
    {
      "path": "registry/ui/benday/presets.ts",
      "content": "import type { DotFrame, Preset, PresetName } from \"./types\";\n\nconst TAU = Math.PI * 2;\nconst fract = (v: number) => v - Math.floor(v);\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\n/** Signed wrapped difference in a 0..1 cyclic space, -0.5..0.5. */\nconst cyclicDelta = (v: number) => {\n  const d = fract(v);\n  return d > 0.5 ? d - 1 : d;\n};\n\nconst gauss = (d: number, sigma: number) =>\n  Math.exp(-(d * d) / (2 * sigma * sigma));\n\nfunction smoothstep(edge0: number, edge1: number, value: number): number {\n  const x = clamp01((value - edge0) / (edge1 - edge0));\n  return x * x * (3 - 2 * x);\n}\n\nfunction hash2(x: number, y: number): number {\n  const s = Math.sin(x * 127.1 + y * 311.7) * 43_758.5453;\n  return s - Math.floor(s);\n}\n\n/** Smoothed 2D value noise, 0..1. */\nfunction noise2(x: number, y: number): number {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  let fx = x - xi;\n  let fy = y - yi;\n  fx = fx * fx * (3 - 2 * fx);\n  fy = fy * fy * (3 - 2 * fy);\n  const a = hash2(xi, yi);\n  const b = hash2(xi + 1, yi);\n  const c = hash2(xi, yi + 1);\n  const d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;\n}\n\n/* Each preset writes into a reused `out` — it runs once per dot per frame, so\n   allocating there would churn the heap at 60fps × 500 dots. */\n\n/** A lit band sweeps the mark on the diagonal — the shimmer-text idiom, in dots. */\nconst shimmer: Preset = (c, t, out) => {\n  const u = c.x * 0.72 + c.y * 0.28;\n  const phase = cyclicDelta(u - t * 0.42);\n  const g = gauss(phase, 0.11);\n  out.a = 0.2 + 0.8 * g;\n  out.s = 0.8 + 0.4 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Concentric rings pulse outward from the mark's center. */\nconst ripple: Preset = (c, t, out) => {\n  const w = (Math.sin(t * 2.4 - c.r * 7) + 1) / 2;\n  const g = w * w;\n  out.a = 0.18 + 0.82 * g;\n  out.s = 0.76 + 0.42 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** A wave travels along the shape's own thickness: outline first, core last. */\nconst contour: Preset = (c, t, out) => {\n  const w = (Math.sin(t * 2.2 - c.d * 6.5) + 1) / 2;\n  const g = w ** 1.6;\n  out.a = 0.16 + 0.84 * g;\n  out.s = 0.7 + 0.55 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Dots drift off the lattice on a noise field, then reconverge into the mark. */\nconst scatter: Preset = (c, t, out) => {\n  const spread = 0.5 - 0.5 * Math.cos(t * 0.85);\n  const amp = spread * 2.4;\n  const nx = noise2(c.x * 3.1 + t * 0.22, c.y * 3.1) * 2 - 1;\n  const ny = noise2(c.x * 3.1 + 17.3, c.y * 3.1 - t * 0.22) * 2 - 1;\n  const jitter = (c.rand - 0.5) * 0.6 * spread;\n  out.dx = nx * amp + jitter;\n  out.dy = ny * amp - jitter;\n  out.a = 1 - 0.5 * spread;\n  out.s = 1 - 0.28 * spread;\n};\n\n/** A random subset of dots blinks at any moment. */\nconst flicker: Preset = (c, t, out) => {\n  const phase = fract(t * 0.7 + c.rand);\n  const g = phase < 0.3 ? Math.sin((phase / 0.3) * Math.PI) : 0;\n  out.a = 0.14 + 0.86 * g;\n  out.s = 0.82 + 0.34 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** The whole mark swells and settles, with a slight delay toward the edges. */\nconst breathe: Preset = (c, t, out) => {\n  const g = (Math.sin(t * 1.5 - c.r * 1.1) + 1) / 2;\n  out.a = 0.42 + 0.58 * g;\n  out.s = 0.86 + 0.24 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** The mark twists around its center, outer dots lagging the inner ones. */\nconst swirl: Preset = (c, t, out) => {\n  const rotation = 0.45 * Math.sin(t * 1.3 - c.r * 2.4);\n  const cos = Math.cos(rotation);\n  const sin = Math.sin(rotation);\n  const rx = c.nx * cos - c.ny * sin;\n  const ry = c.nx * sin + c.ny * cos;\n  out.dx = (rx - c.nx) * (c.cols / 2);\n  out.dy = (ry - c.ny) * (c.rows / 2);\n  const swing = Math.abs(rotation) / 0.45;\n  out.a = 0.55 + 0.45 * (1 - swing);\n  out.s = 0.9 + 0.15 * (1 - swing);\n};\n\n/** A narrow vertical beam traverses the complete silhouette. */\nconst scan: Preset = (c, t, out) => {\n  const phase = cyclicDelta(c.x - t * 0.34);\n  const g = gauss(phase, 0.075);\n  out.a = 0.2 + 0.8 * g;\n  out.s = 0.76 + 0.42 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** One long signal follows the baked lattice in row-wise serpentine order. */\nconst cascade: Preset = (c, t, out) => {\n  const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));\n  const across = row % 2 === 0 ? c.x : 1 - c.x;\n  const path = (row + across) / c.rows;\n  const phase = cyclicDelta(path - t * 0.24);\n  const g = gauss(phase, 0.055);\n  out.a = 0.16 + 0.84 * g;\n  out.s = 0.72 + 0.5 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** A soft energy point circles the center without rotating the logo itself. */\nconst orbit: Preset = (c, t, out) => {\n  const angle = t * 1.25;\n  const ox = Math.cos(angle) * 0.62;\n  const oy = Math.sin(angle) * 0.62;\n  const distance = Math.hypot(c.nx - ox, c.ny - oy);\n  const g = gauss(distance, 0.3);\n  out.a = 0.2 + 0.8 * g;\n  out.s = 0.76 + 0.44 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** A bright head and tapered tail chase clockwise around the mark. */\nconst comet: Preset = (c, t, out) => {\n  const angle = fract(c.angle / TAU);\n  const behind = fract(t * 0.2 - angle);\n  const trail = Math.exp(-behind * 7);\n  const ring = 0.35 + 0.65 * smoothstep(0.12, 0.72, c.r);\n  const g = trail * ring;\n  out.a = 0.18 + 0.82 * g;\n  out.s = 0.75 + 0.48 * g;\n  out.dx = -c.ny * g * 0.26;\n  out.dy = c.nx * g * 0.26;\n};\n\n/** A rotating search beam crosses the full logo with a soft angular wake. */\nconst radar: Preset = (c, t, out) => {\n  const angle = fract(c.angle / TAU);\n  const head = fract(t * 0.16);\n  const delta = cyclicDelta(angle - head);\n  const beam = gauss(delta, 0.045);\n  const wake = delta < 0 ? Math.exp(delta * 8) * 0.42 : 0;\n  const g = Math.max(beam, wake) * (0.72 + c.r * 0.28);\n  out.a = 0.18 + 0.82 * g;\n  out.s = 0.76 + 0.42 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Three curved blades rotate through the mark around a steady core. */\nconst pinwheel: Preset = (c, t, out) => {\n  const wave = (Math.sin(c.angle * 3 - t * 2.1 - c.r * 3.4) + 1) / 2;\n  const g = wave ** 2.2;\n  const core = gauss(c.r, 0.18);\n  out.a = 0.18 + 0.82 * Math.max(g, core);\n  out.s = 0.76 + 0.4 * Math.max(g, core);\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** A soft traveling current bends the lattice without breaking the silhouette. */\nconst wave: Preset = (c, t, out) => {\n  const phase = t * 1.7 - c.y * 6.2;\n  const crest = (Math.sin(phase) + 1) / 2;\n  const sway = Math.sin(phase) * (0.28 + c.r * 0.42);\n  out.a = 0.46 + 0.54 * crest;\n  out.s = 0.86 + 0.24 * crest;\n  out.dx = sway;\n  out.dy = Math.cos(phase - c.x * 1.4) * 0.08;\n};\n\n/** Independent column levels rise and fall like a restrained spectrum display. */\nconst equalizer: Preset = (c, t, out) => {\n  const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));\n  const phase = t * 1.85 + col * 0.73 + hash2(col, 4.2) * 1.4;\n  const level = 0.2 + 0.75 * ((Math.sin(phase) + 1) / 2);\n  const g = 1 - smoothstep(level - 0.08, level + 0.08, Math.abs(c.ny));\n  out.a = 0.18 + 0.82 * g;\n  out.s = 0.76 + 0.4 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Counter-moving diagonal bands cross and briefly brighten where they meet. */\nconst weave: Preset = (c, t, out) => {\n  const a = gauss(cyclicDelta(c.x * 0.62 + c.y * 0.38 - t * 0.3), 0.08);\n  const b = gauss(cyclicDelta(c.x * 0.62 - c.y * 0.38 + t * 0.24), 0.08);\n  const g = clamp01(Math.max(a, b, Math.min(a, b) * 1.25));\n  out.a = 0.18 + 0.82 * g;\n  out.s = 0.76 + 0.44 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Emphasis hands off between north, east, south and west. */\nconst beacon: Preset = (c, t, out) => {\n  let sector = 0;\n  if (Math.abs(c.nx) > Math.abs(c.ny)) {\n    sector = c.nx > 0 ? 0.25 : 0.75;\n  } else if (c.ny > 0) {\n    sector = 0.5;\n  }\n  const phase = cyclicDelta(sector - t * 0.28);\n  const g = gauss(phase, 0.1) * (0.55 + c.r * 0.45);\n  out.a = 0.2 + 0.8 * g;\n  out.s = 0.78 + 0.4 * g;\n  out.dx = 0;\n  out.dy = 0;\n};\n\n/** Brief horizontal faults disturb a few rows, then snap cleanly home. */\nconst glitch: Preset = (c, t, out) => {\n  const row = Math.min(c.rows - 1, Math.floor(c.y * c.rows));\n  const cycle = Math.floor(t * 0.48 + 0.5);\n  const burst = gauss(cyclicDelta(t * 0.48), 0.055);\n  const slice = hash2(row, cycle);\n  const active = slice > 0.58 ? burst : 0;\n  out.a = 1 - active * 0.34;\n  out.s = 1 - active * 0.12;\n  out.dx = (slice - 0.5) * 3.2 * active;\n  out.dy = 0;\n};\n\n/** Offset droplets descend each column with soft tails. */\nconst rain: Preset = (c, t, out) => {\n  const col = Math.min(c.cols - 1, Math.floor(c.x * c.cols));\n  const head = fract(t * 0.34 + hash2(col, 8.1));\n  const delta = cyclicDelta(c.y - head);\n  const trail = delta < 0 ? Math.exp(delta * 6) : Math.exp(-delta * 18);\n  out.a = 0.16 + 0.84 * trail;\n  out.s = 0.74 + 0.44 * trail;\n  out.dx = 0;\n  out.dy = trail * 0.18;\n};\n\n/** The field is pulled toward its center and released without losing its shape. */\nconst magnetic: Preset = (c, t, out) => {\n  const pull = 0.5 - 0.5 * Math.cos(t * 1.15);\n  const strength = pull * (0.35 + c.r * 0.65);\n  out.dx = -c.nx * c.cols * 0.12 * strength;\n  out.dy = -c.ny * c.rows * 0.12 * strength;\n  out.a = 1 - pull * 0.28;\n  out.s = 1 - pull * 0.2;\n};\n\n/** Dots assemble in a stable random order, hold, then dissolve to begin again. */\nconst resolve: Preset = (c, t, out) => {\n  const progress = 0.5 - 0.5 * Math.cos(t * 1.08);\n  const g = smoothstep(c.rand - 0.12, c.rand + 0.12, progress);\n  out.a = 0.14 + 0.86 * g;\n  out.s = 0.66 + 0.34 * g;\n  out.dx = c.nx * (1 - g) * 0.38;\n  out.dy = c.ny * (1 - g) * 0.38;\n};\n\nexport type PresetFamily =\n  | \"Signature\"\n  | \"Sweep\"\n  | \"Orbit\"\n  | \"Field\"\n  | \"Transform\";\n\nexport interface PresetDefinition {\n  name: PresetName;\n  label: string;\n  description: string;\n  family: PresetFamily;\n  fn: Preset;\n}\n\nexport const PRESET_FAMILIES: PresetFamily[] = [\n  \"Signature\",\n  \"Sweep\",\n  \"Orbit\",\n  \"Field\",\n  \"Transform\",\n];\n\nexport const PRESETS: Record<PresetName, PresetDefinition> = {\n  beacon: {\n    description: \"Emphasis hands off between the four cardinal directions.\",\n    family: \"Orbit\",\n    fn: beacon,\n    label: \"Beacon\",\n    name: \"beacon\",\n  },\n  breathe: {\n    description: \"The whole mark swells and settles with a soft edge delay.\",\n    family: \"Signature\",\n    fn: breathe,\n    label: \"Breathe\",\n    name: \"breathe\",\n  },\n  cascade: {\n    description: \"One signal follows the lattice in serpentine order.\",\n    family: \"Sweep\",\n    fn: cascade,\n    label: \"Cascade\",\n    name: \"cascade\",\n  },\n  comet: {\n    description: \"A bright head and tapered tail chase around the mark.\",\n    family: \"Orbit\",\n    fn: comet,\n    label: \"Comet\",\n    name: \"comet\",\n  },\n  contour: {\n    description: \"A wave follows the mark’s thickness from outline to core.\",\n    family: \"Signature\",\n    fn: contour,\n    label: \"Contour\",\n    name: \"contour\",\n  },\n  equalizer: {\n    description: \"Independent column levels rise and fall like a spectrum.\",\n    family: \"Field\",\n    fn: equalizer,\n    label: \"Equalizer\",\n    name: \"equalizer\",\n  },\n  flicker: {\n    description: \"A stable random subset of dots blinks at any moment.\",\n    family: \"Field\",\n    fn: flicker,\n    label: \"Flicker\",\n    name: \"flicker\",\n  },\n  glitch: {\n    description: \"Brief horizontal faults disturb a few rows, then clear.\",\n    family: \"Transform\",\n    fn: glitch,\n    label: \"Glitch\",\n    name: \"glitch\",\n  },\n  magnetic: {\n    description: \"The field pulls toward its center and releases.\",\n    family: \"Transform\",\n    fn: magnetic,\n    label: \"Magnetic\",\n    name: \"magnetic\",\n  },\n  orbit: {\n    description: \"A soft energy point circles the center of the mark.\",\n    family: \"Orbit\",\n    fn: orbit,\n    label: \"Orbit\",\n    name: \"orbit\",\n  },\n  pinwheel: {\n    description: \"Three curved blades rotate around a steady core.\",\n    family: \"Orbit\",\n    fn: pinwheel,\n    label: \"Pinwheel\",\n    name: \"pinwheel\",\n  },\n  radar: {\n    description: \"A rotating search beam crosses the logo with a soft wake.\",\n    family: \"Orbit\",\n    fn: radar,\n    label: \"Radar\",\n    name: \"radar\",\n  },\n  rain: {\n    description: \"Offset droplets descend each column with soft tails.\",\n    family: \"Sweep\",\n    fn: rain,\n    label: \"Rain\",\n    name: \"rain\",\n  },\n  resolve: {\n    description: \"Dots assemble in stable random order, then dissolve.\",\n    family: \"Field\",\n    fn: resolve,\n    label: \"Resolve\",\n    name: \"resolve\",\n  },\n  ripple: {\n    description: \"Concentric rings pulse outward from the center.\",\n    family: \"Signature\",\n    fn: ripple,\n    label: \"Ripple\",\n    name: \"ripple\",\n  },\n  scan: {\n    description: \"A narrow vertical beam traverses the silhouette.\",\n    family: \"Sweep\",\n    fn: scan,\n    label: \"Scan\",\n    name: \"scan\",\n  },\n  scatter: {\n    description: \"Dots drift off the lattice, then reconverge into the mark.\",\n    family: \"Transform\",\n    fn: scatter,\n    label: \"Scatter\",\n    name: \"scatter\",\n  },\n  shimmer: {\n    description: \"A lit band sweeps across the mark on the diagonal.\",\n    family: \"Signature\",\n    fn: shimmer,\n    label: \"Shimmer\",\n    name: \"shimmer\",\n  },\n  swirl: {\n    description: \"The mark twists around its center, with outer dots lagging.\",\n    family: \"Orbit\",\n    fn: swirl,\n    label: \"Swirl\",\n    name: \"swirl\",\n  },\n  wave: {\n    description: \"A soft traveling current bends the dot lattice.\",\n    family: \"Field\",\n    fn: wave,\n    label: \"Wave\",\n    name: \"wave\",\n  },\n  weave: {\n    description: \"Counter-moving diagonal bands cross through the mark.\",\n    family: \"Sweep\",\n    fn: weave,\n    label: \"Weave\",\n    name: \"weave\",\n  },\n};\n\n/** Curated gallery order: approachable signatures before more expressive motion. */\nexport const PRESET_NAMES: PresetName[] = [\n  \"contour\",\n  \"shimmer\",\n  \"ripple\",\n  \"breathe\",\n  \"scan\",\n  \"cascade\",\n  \"weave\",\n  \"rain\",\n  \"swirl\",\n  \"orbit\",\n  \"comet\",\n  \"radar\",\n  \"pinwheel\",\n  \"beacon\",\n  \"flicker\",\n  \"wave\",\n  \"equalizer\",\n  \"resolve\",\n  \"scatter\",\n  \"magnetic\",\n  \"glitch\",\n];\n\n/** Per-dot randomness that stays stable across frames. */\nexport function dotRandom(i: number): number {\n  return hash2(i * 0.371, i * 0.917 + 3.14);\n}\n\nexport function makeFrame(): DotFrame {\n  return { a: 1, dx: 0, dy: 0, s: 1 };\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/presets.ts"
    },
    {
      "path": "registry/ui/benday/renderer.ts",
      "content": "import {\n  devicePixelRatioCapped,\n  prefersReducedMotion,\n  resolveInk,\n  watchPaintability,\n  watchReducedMotion,\n  watchTheme,\n} from \"./dom\";\nimport { PRESETS, dotRandom } from \"./presets\";\nimport type {\n  DotContext,\n  DotFrame,\n  DotMap,\n  DotShape,\n  Preset,\n  Renderer,\n  RendererOptions,\n  ResolvedRendererOptions,\n} from \"./types\";\n\nexport const DEFAULT_RENDERER_OPTIONS: ResolvedRendererOptions = {\n  color: \"currentColor\",\n  dotMap: null,\n  dotScale: 0.62,\n  fit: \"square\",\n  glow: 0,\n  padding: 0.06,\n  paused: false,\n  preset: \"contour\",\n  reducedMotion: \"auto\",\n  shape: \"circle\",\n  size: 64,\n  speed: 1,\n  state: \"thinking\",\n  weight: 0.5,\n};\n\n/** Light spring for the thinking ↔ crisp transition. Slightly under-damped. */\nconst STIFFNESS = 140;\nconst DAMPING = 20;\nconst MAX_TIMESTEP = 0.05;\n/** Below this, separate cells become subpixel haze rather than a dot lattice. */\nconst MIN_AUTO_CELL_PX = 2;\n\n/** Options whose change invalidates the cached per-dot contexts. */\nconst DERIVED_KEYS = [\"dotMap\", \"fit\", \"padding\", \"size\", \"weight\"] as const;\n/** Options whose change invalidates the cached canvas geometry. */\nconst LAYOUT_KEYS = [\"dotMap\", \"size\", \"fit\", \"padding\", \"dotScale\"] as const;\n\n/**\n * Paint a {@link DotMap} onto a canvas and animate it. One per canvas, updated\n * through {@link Renderer.update}: the clock lives here, so props never restart it.\n */\nexport function createRenderer(\n  canvas: HTMLCanvasElement,\n  initial: RendererOptions = {}\n): Renderer {\n  const context = canvas.getContext(\"2d\");\n  if (!context) {\n    throw new Error(\"benday: could not acquire a 2D context\");\n  }\n  const ctx = context;\n\n  let opts: ResolvedRendererOptions = {\n    ...DEFAULT_RENDERER_OPTIONS,\n    ...strip(initial),\n  };\n\n  // Caches, rebuilt only when their inputs change.\n  let dots: DotContext[] = [];\n  let toneScales: number[] = [];\n  let displayCols = 1;\n  let displayRows = 1;\n  let ink = \"#000\";\n  let cssWidth = 0;\n  let cssHeight = 0;\n  let cell = 0;\n  let originX = 0;\n  let originY = 0;\n  let baseRadius = 0;\n\n  // Animation state, deliberately outliving update().\n  let clock = 0;\n  let settle = 1;\n  let velocity = 0;\n  let frameId = 0;\n  let running = false;\n  let lastFrame = 0;\n  let paintable = true;\n  let systemReducedMotion = prefersReducedMotion();\n\n  const frame: DotFrame = { a: 1, dx: 0, dy: 0, s: 1 };\n\n  function isReduced(): boolean {\n    return opts.reducedMotion === \"auto\"\n      ? systemReducedMotion\n      : opts.reducedMotion;\n  }\n\n  function presetFn(): Preset {\n    return typeof opts.preset === \"function\"\n      ? opts.preset\n      : PRESETS[opts.preset].fn;\n  }\n\n  function buildDerived(): void {\n    const map = opts.dotMap;\n    if (!map) {\n      dots = [];\n      toneScales = [];\n      displayCols = 1;\n      displayRows = 1;\n      return;\n    }\n\n    const display = buildDisplayMap(map, opts);\n    displayCols = display.cols;\n    displayRows = display.rows;\n    dots = display.dots.map((dot, i) => {\n      const nx = dot.x * 2 - 1;\n      const ny = dot.y * 2 - 1;\n      return {\n        angle: Math.atan2(ny, nx),\n        cols: displayCols,\n        d: dot.d,\n        i,\n        n: display.dots.length,\n        nx,\n        ny,\n        r: Math.min(1, Math.hypot(nx, ny) / Math.SQRT2),\n        rand: dotRandom(i),\n        rows: displayRows,\n        t: dot.t,\n        v: dot.v,\n        x: dot.x,\n        y: dot.y,\n      };\n    });\n    const opticalSize =\n      opts.fit === \"natural\"\n        ? Math.min(opts.size, opts.size / safeAspect(map))\n        : opts.size;\n    const optical = opticalFactor(opticalSize);\n    toneScales = display.dots.map((dot) =>\n      toneScale(dot.v, dot.t, dot.d, opts.weight, optical)\n    );\n  }\n\n  function buildLayout(): void {\n    const map = opts.dotMap;\n    const cols = map ? displayCols : 1;\n    const rows = map ? displayRows : 1;\n\n    cssWidth = opts.size;\n    cssHeight =\n      opts.fit === \"natural\" ? opts.size / safeAspect(map) : opts.size;\n\n    const dpr = devicePixelRatioCapped();\n    canvas.width = Math.round(cssWidth * dpr);\n    canvas.height = Math.round(cssHeight * dpr);\n    canvas.style.width = `${cssWidth}px`;\n    canvas.style.height = `${cssHeight}px`;\n\n    const padding = opticalPadding(opts.padding, Math.min(cssWidth, cssHeight));\n    const availableWidth = cssWidth * (1 - padding * 2);\n    const availableHeight = cssHeight * (1 - padding * 2);\n    cell = Math.min(availableWidth / cols, availableHeight / rows);\n    // A fine lattice lands every dot on a different fraction of a device pixel,\n    // so each antialiases differently and the grid dissolves. Only small marks\n    // need it, and only if the snapped lattice still fits the canvas.\n    if (opticalFactor(Math.min(cssWidth, cssHeight)) > 0) {\n      const devicePixel = 1 / dpr;\n      const snapped = Math.max(\n        devicePixel,\n        Math.round(cell / devicePixel) * devicePixel\n      );\n      if (snapped * cols <= cssWidth && snapped * rows <= cssHeight) {\n        cell = snapped;\n      }\n    }\n    originX = snapTo((cssWidth - cell * cols) / 2, dpr);\n    originY = snapTo((cssHeight - cell * rows) / 2, dpr);\n    baseRadius =\n      ((cell * opts.dotScale) / 2) *\n      (1 + opticalFactor(Math.min(cssWidth, cssHeight)) * 0.25);\n\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  }\n\n  function buildInk(): void {\n    ink = resolveInk(canvas, opts.color);\n  }\n\n  function paint(): void {\n    ctx.clearRect(0, 0, cssWidth, cssHeight);\n    if (dots.length === 0) {\n      return;\n    }\n\n    ctx.fillStyle = ink;\n    const optical = opticalFactor(Math.min(cssWidth, cssHeight));\n    // Only `glow` blurs. Small marks used to get a halo here for optical\n    // weight, but at 16px it came out wider than the cell and the lattice bled\n    // into haze; `optical` buys that weight through radius and alpha instead.\n    if (opts.glow > 0) {\n      ctx.shadowColor = ink;\n      ctx.shadowBlur = baseRadius * 4 * opts.glow;\n    } else {\n      ctx.shadowBlur = 0;\n    }\n\n    const run = presetFn();\n    const cols = displayCols;\n    const rows = displayRows;\n    const motion = (1 - settle) * (1 - optical * 0.65);\n\n    for (let i = 0; i < dots.length; i++) {\n      const dot = dots[i] as DotContext;\n      frame.s = 1;\n      frame.a = 1;\n      frame.dx = 0;\n      frame.dy = 0;\n      run(dot, clock, frame);\n\n      // settle = 1 is the crisp logo; blend every channel toward it.\n      const settledScale = frame.s + (1 - frame.s) * settle;\n      const settledAlpha = frame.a + (1 - frame.a) * settle;\n      const scale = settledScale + (1 - settledScale) * optical * 0.55;\n      const alpha = settledAlpha + (1 - settledAlpha) * optical * 0.8;\n      const radius =\n        baseRadius * scale * (toneScales[i] as number) * shapeScale(opts.shape);\n      if (radius <= 0.05) {\n        continue;\n      }\n      const opacity = Math.min(1, Math.max(0, alpha));\n      if (opacity <= 0.004) {\n        continue;\n      }\n\n      ctx.globalAlpha = opacity;\n      drawDot(\n        ctx,\n        opts.shape,\n        originX + (dot.x * cols + frame.dx * motion) * cell,\n        originY + (dot.y * rows + frame.dy * motion) * cell,\n        radius\n      );\n    }\n\n    ctx.globalAlpha = 1;\n    ctx.shadowBlur = 0;\n  }\n\n  function settleTarget(): number {\n    return opts.state === \"thinking\" ? 0 : 1;\n  }\n\n  function isSettled(): boolean {\n    return settle === settleTarget() && velocity === 0;\n  }\n\n  function shouldAnimate(): boolean {\n    if (isReduced() || opts.paused || !paintable) {\n      return false;\n    }\n    return opts.state === \"thinking\" || !isSettled();\n  }\n\n  function step(now: number): void {\n    const dt = lastFrame\n      ? Math.min(MAX_TIMESTEP, (now - lastFrame) / 1000)\n      : 1 / 60;\n    lastFrame = now;\n    clock += dt * opts.speed;\n\n    const target = settleTarget();\n    velocity += ((target - settle) * STIFFNESS - velocity * DAMPING) * dt;\n    settle += velocity * dt;\n    if (Math.abs(target - settle) < 0.001 && Math.abs(velocity) < 0.01) {\n      settle = target;\n      velocity = 0;\n    }\n\n    paint();\n\n    if (running && shouldAnimate()) {\n      frameId = requestAnimationFrame(step);\n    } else {\n      running = false;\n    }\n  }\n\n  function start(): void {\n    if (running) {\n      return;\n    }\n    running = true;\n    lastFrame = 0;\n    frameId = requestAnimationFrame(step);\n  }\n\n  function stop(): void {\n    running = false;\n    cancelAnimationFrame(frameId);\n  }\n\n  function sync(): void {\n    if (shouldAnimate()) {\n      start();\n    } else {\n      stop();\n    }\n  }\n\n  // Reduced motion means \"show me the mark, not the animation\".\n  function applyReducedMotion(): void {\n    if (isReduced()) {\n      settle = 1;\n      velocity = 0;\n    }\n  }\n\n  const stopTheme = watchTheme(() => {\n    buildInk();\n    if (!running) {\n      paint();\n    }\n  });\n\n  const stopReducedMotion = watchReducedMotion((reduced) => {\n    systemReducedMotion = reduced;\n    applyReducedMotion();\n    paint();\n    sync();\n  });\n\n  const stopPaintability = watchPaintability(canvas, (next) => {\n    paintable = next;\n    sync();\n  });\n\n  buildDerived();\n  buildLayout();\n  buildInk();\n  applyReducedMotion();\n  paint();\n  sync();\n\n  return {\n    destroy(): void {\n      stop();\n      stopTheme();\n      stopReducedMotion();\n      stopPaintability();\n    },\n\n    get height(): number {\n      return cssHeight;\n    },\n\n    get options(): Readonly<ResolvedRendererOptions> {\n      return opts;\n    },\n\n    update(next: RendererOptions): void {\n      const previous = opts;\n      opts = { ...opts, ...strip(next) };\n\n      if (DERIVED_KEYS.some((key) => previous[key] !== opts[key])) {\n        buildDerived();\n      }\n      if (LAYOUT_KEYS.some((key) => previous[key] !== opts[key])) {\n        buildLayout();\n      }\n      if (previous.color !== opts.color) {\n        buildInk();\n      }\n      applyReducedMotion();\n\n      paint();\n      sync();\n    },\n  };\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value));\n}\n\n/** Round a CSS-pixel position onto the backing store's pixel grid. */\nfunction snapTo(value: number, dpr: number): number {\n  return Math.round(value * dpr) / dpr;\n}\n\nfunction clamp01(value: number): number {\n  return clamp(value, 0, 1);\n}\n\ninterface DisplayDot {\n  d: number;\n  t: number;\n  v: number;\n  x: number;\n  y: number;\n}\n\ninterface DisplayMap {\n  cols: number;\n  dots: DisplayDot[];\n  rows: number;\n}\n\n/**\n * Downsample an over-dense map into a coarser regular lattice. Missing source\n * dots count as empty cells, so the aggregate retains the source's ink mass\n * instead of merely making every surviving dot larger.\n */\nfunction buildDisplayMap(\n  map: DotMap,\n  opts: ResolvedRendererOptions\n): DisplayMap {\n  const sourceCols = Math.max(1, Math.round(map.cols));\n  const sourceRows = Math.max(1, Math.round(map.rows));\n  const source = { cols: sourceCols, dots: map.dots, rows: sourceRows };\n  if (map.dots.length === 0) {\n    return source;\n  }\n\n  const width = Math.max(1, opts.size);\n  const height = opts.fit === \"natural\" ? width / safeAspect(map) : width;\n  const padding = opticalPadding(opts.padding, Math.min(width, height));\n  const cell = Math.min(\n    (width * (1 - padding * 2)) / sourceCols,\n    (height * (1 - padding * 2)) / sourceRows\n  );\n  if (!Number.isFinite(cell) || cell >= MIN_AUTO_CELL_PX) {\n    return source;\n  }\n\n  const scale = cell / MIN_AUTO_CELL_PX;\n  const cols = Math.max(1, Math.floor(sourceCols * scale));\n  const rows = Math.max(1, Math.floor(sourceRows * scale));\n  if (cols === sourceCols && rows === sourceRows) {\n    return source;\n  }\n\n  const bins = Array.from({ length: cols * rows }, () => ({\n    depth: 0,\n    ink: 0,\n    tone: 0,\n  }));\n  for (const dot of map.dots) {\n    const sourceCol = clamp(Math.floor(dot.col), 0, sourceCols - 1);\n    const sourceRow = clamp(Math.floor(dot.row), 0, sourceRows - 1);\n    const col = Math.min(cols - 1, Math.floor((sourceCol * cols) / sourceCols));\n    const row = Math.min(rows - 1, Math.floor((sourceRow * rows) / sourceRows));\n    const bin = bins[row * cols + col];\n    if (!bin) {\n      continue;\n    }\n    const coverage = clamp01(dot.v);\n    bin.ink += coverage;\n    bin.depth += clamp01(dot.d) * coverage;\n    bin.tone += clamp01(dot.t) * coverage;\n  }\n\n  const dots: DisplayDot[] = [];\n  for (let row = 0; row < rows; row++) {\n    const sourceRowStart = Math.ceil((row * sourceRows) / rows);\n    const sourceRowEnd = Math.ceil(((row + 1) * sourceRows) / rows);\n    for (let col = 0; col < cols; col++) {\n      const bin = bins[row * cols + col];\n      if (!bin || bin.ink <= 0) {\n        continue;\n      }\n      const sourceColStart = Math.ceil((col * sourceCols) / cols);\n      const sourceColEnd = Math.ceil(((col + 1) * sourceCols) / cols);\n      const sourceCells = Math.max(\n        1,\n        (sourceColEnd - sourceColStart) * (sourceRowEnd - sourceRowStart)\n      );\n      const coverage = clamp01(bin.ink / sourceCells);\n      if (coverage <= 0.004) {\n        continue;\n      }\n      dots.push({\n        d: bin.depth / bin.ink,\n        t: bin.tone / bin.ink,\n        v: coverage,\n        x: (col + 0.5) / cols,\n        y: (row + 0.5) / rows,\n      });\n    }\n  }\n\n  return { cols, dots, rows };\n}\n\n/**\n * A halftone represents coverage through area: radius therefore follows the\n * square root of tone. Opacity remains available to the animation instead of\n * applying coverage twice (radius, then alpha), which used to erase fine ink.\n */\nfunction toneScale(\n  coverage: number,\n  sourceTone: number,\n  distance: number,\n  weight: number,\n  optical: number\n): number {\n  const weightedCoverage =\n    1 - clamp01(weight) + clamp01(weight) * clamp01(coverage);\n  const d = clamp01(distance);\n  const smoothDepth = d * d * (3 - 2 * d);\n  const depthTone = 0.72 + smoothDepth * 0.38;\n  const layerFloor = 0.18 + optical * 0.14;\n  const layerTone = layerFloor + clamp01(sourceTone) * (1 - layerFloor);\n  const tone = weightedCoverage * layerTone * (0.82 + depthTone * 0.18);\n  return Math.sqrt(clamp01(tone));\n}\n\n/** Small marks need optical weight and restrained motion to remain identifiable. */\nfunction opticalFactor(size: number): number {\n  return clamp01((32 - size) / 16);\n}\n\nfunction opticalPadding(padding: number, size: number): number {\n  return clamp(padding, 0, 0.49) * (1 - opticalFactor(size) * 0.65);\n}\n\n/** Keep circle, square and diamond at equal painted area for the same tone. */\nfunction shapeScale(shape: DotShape): number {\n  if (shape === \"square\") {\n    return Math.sqrt(Math.PI / 4);\n  }\n  if (shape === \"diamond\") {\n    return Math.sqrt(Math.PI / 2);\n  }\n  return 1;\n}\n\nfunction safeAspect(map: DotMap | null): number {\n  if (map && Number.isFinite(map.aspect) && map.aspect > 0) {\n    return map.aspect;\n  }\n  return map ? map.cols / Math.max(1, map.rows) : 1;\n}\n\nfunction drawDot(\n  ctx: CanvasRenderingContext2D,\n  shape: DotShape,\n  x: number,\n  y: number,\n  r: number\n): void {\n  if (shape === \"circle\") {\n    ctx.beginPath();\n    ctx.arc(x, y, r, 0, Math.PI * 2);\n    ctx.fill();\n    return;\n  }\n  if (shape === \"square\") {\n    ctx.fillRect(x - r, y - r, r * 2, r * 2);\n    return;\n  }\n  ctx.beginPath();\n  ctx.moveTo(x, y - r);\n  ctx.lineTo(x + r, y);\n  ctx.lineTo(x, y + r);\n  ctx.lineTo(x - r, y);\n  ctx.closePath();\n  ctx.fill();\n}\n\n/** Drop explicit `undefined` so spreading options never clobbers a default. */\nfunction strip(options: RendererOptions): RendererOptions {\n  const out: Record<string, unknown> = {};\n  for (const [key, value] of Object.entries(options)) {\n    if (value !== undefined) {\n      out[key] = value;\n    }\n  }\n  return out as RendererOptions;\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/renderer.ts"
    },
    {
      "path": "registry/ui/benday/types.ts",
      "content": "/** A single dot sampled out of the source image. */\nexport interface Dot {\n  /** Grid column. */\n  col: number;\n  /** Grid row. */\n  row: number;\n  /** Cell-center x, normalized 0..1 across the trimmed content box. */\n  x: number;\n  /** Cell-center y, normalized 0..1 across the trimmed content box. */\n  y: number;\n  /** Ink coverage of the cell, 0..1 (post-gamma). Drives the dot's painted area. */\n  v: number;\n  /** Source tone, 0 for the weakest layer and 1 for the strongest layer. */\n  t: number;\n  /** Depth inside the shape, 0 at the outline → 1 at the thickest point (from the EDT). */\n  d: number;\n}\n\n/**\n * A baked logo: dots plus the geometry to lay them out. All the renderer needs,\n * small and JSON-serializable, so it can be produced at build time.\n */\nexport interface DotMap {\n  /** Grid columns across the trimmed content box. */\n  cols: number;\n  /** Grid rows across the trimmed content box. */\n  rows: number;\n  /** width / height of the trimmed content box. */\n  aspect: number;\n  /** The dots that cleared the coverage threshold. */\n  dots: Dot[];\n  /** How many grid cells were tested (cols * rows) — useful for tuning feedback. */\n  cells: number;\n  /** Which masking strategy actually ran. */\n  maskMode: \"alpha\" | \"luma\";\n}\n\nexport type MaskMode = \"auto\" | \"alpha\" | \"luma\";\n\nexport interface BakeOptions {\n  /** Dots across the longest side of the trimmed logo. @default 24 */\n  grid?: number;\n  /** Cell coverage required to emit a dot, 0..1. @default 0.18 */\n  threshold?: number;\n  /** Gamma applied to coverage before thresholding. <1 boosts faint ink. @default 1 */\n  gamma?: number;\n  /** How to separate ink from background; `auto` picks alpha over luma. @default 'auto' */\n  maskMode?: MaskMode;\n  /** Treat the luminance mask as inverted (light ink on dark). Only used in luma mode. */\n  invert?: boolean;\n  /** Grow the mask by N working pixels — the hairline rescue knob. @default 0 */\n  dilate?: number;\n  /** Trim to the mask's bounding box before gridding. @default true */\n  trim?: boolean;\n  /** Resolution the mask is computed at, longest side. @default auto from grid */\n  workingSize?: number;\n}\n\nexport type ResolvedBakeOptions = Required<Omit<BakeOptions, \"workingSize\">> & {\n  workingSize: number;\n};\n\n/** Per-dot input handed to an animation preset each frame. */\nexport interface DotContext {\n  /** Index in the dot list. */\n  i: number;\n  /** Total dot count. */\n  n: number;\n  /** Normalized position, 0..1. */\n  x: number;\n  y: number;\n  /** Normalized position relative to the mark's center, roughly -1..1. */\n  nx: number;\n  ny: number;\n  /** Radial distance from center, normalized so the corner is ~1. */\n  r: number;\n  /** Angle from center, -PI..PI. */\n  angle: number;\n  /** Ink coverage, 0..1. */\n  v: number;\n  /** Source tone, 0..1. */\n  t: number;\n  /** Depth inside the shape, 0 at the outline → 1 at the core. */\n  d: number;\n  /** Stable pseudo-random value for this dot, 0..1. */\n  rand: number;\n  /** Grid dimensions, so offsets can be reasoned about in cell units. */\n  cols: number;\n  rows: number;\n}\n\n/** What a preset produces for one dot on one frame. */\nexport interface DotFrame {\n  /** Radius multiplier. */\n  s: number;\n  /** Alpha, 0..1. */\n  a: number;\n  /** Offset in cell units (1 = one grid cell). */\n  dx: number;\n  dy: number;\n}\n\nexport type Preset = (c: DotContext, t: number, out: DotFrame) => void;\n\nexport type PresetName =\n  | \"shimmer\"\n  | \"ripple\"\n  | \"contour\"\n  | \"scatter\"\n  | \"flicker\"\n  | \"breathe\"\n  | \"swirl\"\n  | \"scan\"\n  | \"cascade\"\n  | \"orbit\"\n  | \"comet\"\n  | \"radar\"\n  | \"pinwheel\"\n  | \"wave\"\n  | \"equalizer\"\n  | \"weave\"\n  | \"beacon\"\n  | \"glitch\"\n  | \"rain\"\n  | \"magnetic\"\n  | \"resolve\";\n\nexport type BendayState = \"idle\" | \"thinking\" | \"done\";\n\nexport type DotShape = \"circle\" | \"square\" | \"diamond\";\n\nexport type Fit = \"square\" | \"natural\";\n\n/** Everything the renderer needs to paint a frame. */\nexport interface RendererOptions {\n  /** The baked map to draw. `null` paints nothing. */\n  dotMap?: DotMap | null;\n  /** Preset name, or your own per-dot function. @default 'contour' */\n  preset?: PresetName | Preset;\n  /** @default 'thinking' */\n  state?: BendayState;\n  /** Rendered size in CSS pixels. @default 64 */\n  size?: number;\n  /** `natural` sizes the canvas to the mark's aspect, which wordmarks need. @default 'square' */\n  fit?: Fit;\n  /** Animation speed multiplier. @default 1 */\n  speed?: number;\n  /** Dot color. `currentColor` resolves against the canvas. @default 'currentColor' */\n  color?: string;\n  /** Dot diameter as a fraction of the grid cell. @default 0.62 */\n  dotScale?: number;\n  /** @default 'circle' */\n  shape?: DotShape;\n  /** Glow radius as a fraction of the dot size, 0 disables. @default 0 */\n  glow?: number;\n  /** Inset around the mark as a fraction of the box. @default 0.06 */\n  padding?: number;\n  /** How strongly ink coverage drives each dot's painted area, 0..1. @default 0.5 */\n  weight?: number;\n  /** Freeze on the current frame. @default false */\n  paused?: boolean;\n  /** `auto` follows prefers-reduced-motion live; a boolean pins it. @default 'auto' */\n  reducedMotion?: boolean | \"auto\";\n}\n\nexport type ResolvedRendererOptions = Required<\n  Omit<RendererOptions, \"dotMap\">\n> & {\n  dotMap: DotMap | null;\n};\n\n/**\n * A live canvas painter. Update it rather than recreating it, so the clock and\n * the settle spring survive prop changes.\n */\nexport interface Renderer {\n  /** Merge new options in and repaint. Unspecified keys keep their value. */\n  update: (next: RendererOptions) => void;\n  /** The currently applied options. */\n  readonly options: Readonly<ResolvedRendererOptions>;\n  /** Canvas CSS height for the current options. */\n  readonly height: number;\n  /** Tear down observers and cancel any pending frame. */\n  destroy: () => void;\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/types.ts"
    },
    {
      "path": "registry/ui/benday/use-dot-map.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport type { BakeSource } from \"./bake\";\nimport { bake, bakeCached } from \"./bake\";\nimport type { BakeOptions, DotMap } from \"./types\";\n\nexport interface UseDotMapResult {\n  dotMap: DotMap | null;\n  loading: boolean;\n  error: Error | null;\n  /** Milliseconds the last bake took — handy for tuning. */\n  elapsed: number | null;\n}\n\nconst IDLE: UseDotMapResult = {\n  dotMap: null,\n  elapsed: null,\n  error: null,\n  loading: false,\n};\n\nconst LOADING: UseDotMapResult = { ...IDLE, loading: true };\n\n/**\n * Bake a source into a {@link DotMap}, re-running on change. Strings hit the\n * module cache; Blobs and Files bake fresh, having no stable identity.\n */\nexport function useDotMap(\n  source: BakeSource | null | undefined,\n  options: BakeOptions = {}\n): UseDotMapResult {\n  const optionsKey = JSON.stringify(options);\n\n  // A string plus its options determines the result, so the pair keys the\n  // effect. Anything else falls back to reference identity.\n  const signature =\n    typeof source === \"string\" ? `${source}|${optionsKey}` : source;\n\n  const optionsRef = useRef<BakeOptions>(options);\n  useEffect(() => {\n    optionsRef.current = options;\n  });\n\n  const [state, setState] = useState<UseDotMapResult>(source ? LOADING : IDLE);\n  // Reset to a loading frame *during* render when the input changes, rather\n  // than in an effect — an effect would paint one frame of stale results first.\n  const [lastSignature, setLastSignature] = useState(signature);\n  if (signature !== lastSignature) {\n    setLastSignature(signature);\n    setState(source ? LOADING : IDLE);\n  }\n\n  useEffect(() => {\n    if (!source) {\n      return;\n    }\n\n    let live = true;\n    const started = performance.now();\n\n    const run = async () => {\n      try {\n        const dotMap =\n          typeof source === \"string\"\n            ? await bakeCached(source, optionsRef.current)\n            : await bake(source, optionsRef.current);\n        if (live) {\n          setState({\n            dotMap,\n            elapsed: performance.now() - started,\n            error: null,\n            loading: false,\n          });\n        }\n      } catch (error) {\n        if (live) {\n          setState({\n            dotMap: null,\n            elapsed: null,\n            error: error as Error,\n            loading: false,\n          });\n        }\n      }\n    };\n\n    run();\n\n    return () => {\n      live = false;\n    };\n  }, [signature, source]);\n\n  return state;\n}\n",
      "type": "registry:ui",
      "target": "@ui/benday/use-dot-map.ts"
    }
  ],
  "docs": "Point Benday at your logo with src, or ship a pre-baked dotMap for zero work on the client. Full API and recipes: https://benday.kacemmathlouthi.dev/usage",
  "categories": [
    "ai",
    "loader",
    "animation"
  ],
  "type": "registry:ui"
}