{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nav-blue-noise-scrim",
  "title": "Nav Blue Noise Scrim",
  "description": "Dimming backdrop for a command palette or search overlay, dithered with a temporal blue-noise mask that reshuffles every frame instead of a flat tint.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/nav-blue-noise-scrim/component.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// NavBlueNoiseScrim — the dimming backdrop behind a command palette / search\n// overlay, rendered as a TEMPORAL blue-noise dither instead of a flat tint.\n//\n// Spatial mask: a real void-and-cluster point distribution (Ulichney 1993),\n// computed once per matrix size. Unlike the repo's ordered-dither family\n// (background-ascii-dither's Bayer mode, chart-*-dither), which threshold\n// against a small regular 4x4 matrix and read as a visible crosshatch at any\n// zoom, void-and-cluster iteratively balances a binary pattern against its\n// own Gaussian-blurred energy so no two \"on\" cells ever cluster and no\n// region goes empty for long — an aperiodic, non-repeating-looking point set\n// with no dominant frequency. That produces a RANK for every cell in the\n// matrix (0..N-1): thresholding \"rank < coverage * N\" at any coverage level\n// yields a blue-noise-distributed dot set, which is the mechanism ordered\n// dithering is built on but with a fundamentally different point-generation\n// algorithm and a different visual signature (fine, grain-like, isotropic —\n// not a lattice).\n//\n// Temporal reshuffle: real-time renderers get \"temporal blue noise\" by\n// pairing a spatial blue-noise mask with a decorrelated per-frame offset\n// (the animated-noise / golden-ratio-sequence trick used for TAA dithering,\n// e.g. Playdead's INSIDE, Jorge Jimenez's \"Interleaved Gradient Noise\").\n// Every rAF tick we advance a frame-offset by the golden-ratio conjugate mod\n// 1 and threshold `frac(rank / N + offset) < coverage` — every cell cycles\n// through the full rank order over N frames, decorrelated frame to frame,\n// so which dots are lit changes completely each frame while the SPATIAL\n// distribution of \"on\" cells stays blue-noise at every instant. This is an\n// honest approximation of full spatiotemporal blue noise (STBN, which needs\n// a pre-baked 3D noise volume) built from one 2D void-and-cluster mask, not\n// a from-scratch spatiotemporal solve.\n// ---------------------------------------------------------------------------\n\nconst MATRIX_SIZE = 32; // 1024-cell void-and-cluster tile\nconst GOLDEN_CONJUGATE = 0.6180339887498949;\n\n// module-level cache: the matrix is expensive-ish (O(n^2) balancing passes)\n// and fully deterministic for a fixed size, so every mounted instance shares\n// one computation instead of repeating it per scrim.\nlet cachedRanks: Uint32Array | null = null;\n\nfunction computeVoidClusterRanks(size: number): Uint32Array {\n  const n = size * size;\n  const R = 2; // filter radius (5x5 support)\n  const sigma = 1.5;\n  const kernel: number[] = [];\n  for (let dy = -R; dy <= R; dy++) {\n    for (let dx = -R; dx <= R; dx++) {\n      kernel.push(Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma)));\n    }\n  }\n  const idx = (x: number, y: number) =>\n    ((y + size) % size) * size + ((x + size) % size);\n\n  const addEnergy = (\n    energy: Float32Array,\n    x: number,\n    y: number,\n    sign: number\n  ) => {\n    let k = 0;\n    for (let dy = -R; dy <= R; dy++) {\n      for (let dx = -R; dx <= R; dx++) {\n        energy[idx(x + dx, y + dy)] += sign * kernel[k++];\n      }\n    }\n  };\n\n  const tightestCluster = (pattern: Uint8Array, energy: Float32Array) => {\n    let best = -1;\n    let bestE = -Infinity;\n    for (let i = 0; i < n; i++) {\n      if (pattern[i] === 1 && energy[i] > bestE) {\n        bestE = energy[i];\n        best = i;\n      }\n    }\n    return best;\n  };\n  const largestVoid = (pattern: Uint8Array, energy: Float32Array) => {\n    let best = -1;\n    let bestE = Infinity;\n    for (let i = 0; i < n; i++) {\n      if (pattern[i] === 0 && energy[i] < bestE) {\n        bestE = energy[i];\n        best = i;\n      }\n    }\n    return best;\n  };\n\n  // deterministic seed so the mask is stable across reloads/screenshots\n  let seed = 0x9e3779b9;\n  const rand = () => {\n    seed = (seed + 0x6d2b79f5) >>> 0;\n    let t = seed;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n\n  const pattern = new Uint8Array(n);\n  const energy = new Float32Array(n);\n  const initialCount = Math.max(2, Math.round(n * 0.1));\n  const ones = new Set<number>();\n  while (ones.size < initialCount) ones.add(Math.floor(rand() * n));\n  for (const i of ones) {\n    pattern[i] = 1;\n    addEnergy(energy, i % size, Math.floor(i / size), 1);\n  }\n\n  // Phase 1 — balance the initial pattern: swap the tightest cluster for the\n  // largest void until a swap would just reverse itself (converged).\n  for (let iter = 0; iter < initialCount * 4; iter++) {\n    const cluster = tightestCluster(pattern, energy);\n    pattern[cluster] = 0;\n    addEnergy(energy, cluster % size, Math.floor(cluster / size), -1);\n    const voidCell = largestVoid(pattern, energy);\n    if (voidCell === cluster) {\n      pattern[cluster] = 1;\n      addEnergy(energy, cluster % size, Math.floor(cluster / size), 1);\n      break;\n    }\n    pattern[voidCell] = 1;\n    addEnergy(energy, voidCell % size, Math.floor(voidCell / size), 1);\n  }\n\n  const ranks = new Uint32Array(n);\n  const balancedCount = pattern.reduce((a, b) => a + b, 0);\n  // snapshot the converged, balanced pattern before phase 2 consumes it —\n  // phase 3 restarts from this exact state rather than re-deriving it.\n  const p3 = pattern.slice();\n  const e3 = energy.slice();\n\n  // Phase 2 — rank the balanced ones downward (n0-1 .. 0): repeatedly strip\n  // the tightest cluster, so the most-clustered cell gets the lowest rank\n  // and empties first as coverage shrinks.\n  const p2 = pattern;\n  const e2 = energy;\n  let rank = balancedCount - 1;\n  while (rank >= 0) {\n    const cluster = tightestCluster(p2, e2);\n    p2[cluster] = 0;\n    addEnergy(e2, cluster % size, Math.floor(cluster / size), -1);\n    ranks[cluster] = rank;\n    rank--;\n  }\n\n  // Phase 3 — rank the remaining zeros upward (n0 .. N-1) from the snapshot\n  // of the balanced pattern: repeatedly fill the largest void, so cells fill\n  // in the order that keeps the pattern most evenly spread at every coverage.\n  rank = balancedCount;\n  while (rank < n) {\n    const voidCell = largestVoid(p3, e3);\n    p3[voidCell] = 1;\n    addEnergy(e3, voidCell % size, Math.floor(voidCell / size), 1);\n    ranks[voidCell] = rank;\n    rank++;\n  }\n\n  return ranks;\n}\n\nexport interface NavBlueNoiseScrimProps {\n  /** grain cell size in px — kept fine and proportional, never a coarse checkerboard */\n  cellSize?: number;\n  /** mean fraction of cells lit at any instant, 0..1 */\n  coverage?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function NavBlueNoiseScrim({\n  cellSize = 4,\n  coverage = 0.07,\n  className = \"\",\n}: NavBlueNoiseScrimProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  // token derive happens in useLayoutEffect, before first paint, so no rAF /\n  // ResizeObserver / reduced-motion branch can draw with an empty ink string\n  useLayoutEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let bg = \"\";\n    let fg = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bg = cs.getPropertyValue(\"--background\").trim();\n      fg = cs.getPropertyValue(\"--foreground\").trim();\n    };\n    readTokens();\n\n    if (!cachedRanks) cachedRanks = computeVoidClusterRanks(MATRIX_SIZE);\n    const ranks = cachedRanks;\n    const n = MATRIX_SIZE * MATRIX_SIZE;\n\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let cssW = 0;\n    let cssH = 0;\n    let raf = 0;\n    let frame = 0;\n    let disposed = false;\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const rect = canvas.getBoundingClientRect();\n      cssW = rect.width;\n      cssH = rect.height;\n      canvas.width = Math.max(1, Math.round(cssW * dpr));\n      canvas.height = Math.max(1, Math.round(cssH * dpr));\n      cols = Math.ceil(cssW / cellSize);\n      rows = Math.ceil(cssH / cellSize);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const ro = new ResizeObserver(() => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) draw(0);\n      }, 100);\n    });\n    ro.observe(canvas);\n\n    // base dim: a controlled, flat --background wash so the scrim reads as\n    // \"behind the dialog\" even where no grain cell lands this frame — the\n    // grain is texture on TOP of legibility, not the source of it. The\n    // dialog panel itself (see demo) is fully opaque, so nothing drawn here\n    // ever touches its text regardless of coverage or alpha.\n    const BASE_DIM_ALPHA = 0.55;\n    const GRAIN_ALPHA = 0.5;\n\n    const draw = (offset: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, cssW, cssH);\n      ctx.fillStyle = bg;\n      ctx.globalAlpha = BASE_DIM_ALPHA;\n      ctx.fillRect(0, 0, cssW, cssH);\n\n      ctx.fillStyle = fg;\n      ctx.globalAlpha = GRAIN_ALPHA;\n      for (let gy = 0; gy < rows; gy++) {\n        const my = gy % MATRIX_SIZE;\n        for (let gx = 0; gx < cols; gx++) {\n          const mx = gx % MATRIX_SIZE;\n          const rank = ranks[my * MATRIX_SIZE + mx];\n          const t = (rank / n + offset) % 1;\n          if (t < coverage) {\n            ctx.fillRect(gx * cellSize, gy * cellSize, cellSize, cellSize);\n          }\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = () => {\n      frame++;\n      const offset = (frame * GOLDEN_CONJUGATE) % 1;\n      draw(offset);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced) {\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // theme flip re-reads tokens live; frozen reduced-motion frame repaints\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw(0);\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      resize();\n      if (reduced) {\n        // prefers-reduced-motion: freeze to a single static blue-noise mask\n        // instance (offset 0) rather than reshuffling every frame — still\n        // aperiodic and still distinct from Bayer, just not animated. A\n        // per-frame reshuffling grain is exactly the kind of motion this\n        // media query exists to suppress.\n        draw(0);\n      } else {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [cellSize, coverage]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className={`pointer-events-none block h-full w-full ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/nav-blue-noise-scrim.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "nav",
      "scrim",
      "backdrop",
      "overlay",
      "dither",
      "canvas",
      "blue-noise"
    ],
    "instruction": "Build a full-bleed canvas scrim for the area behind a command palette or search overlay, dithered with a real void-and-cluster blue-noise point distribution (Ulichney 1993) instead of a flat --background tint. Void-and-cluster iteratively balances a binary pattern against its own Gaussian-blurred energy so 'on' cells never cluster and no region empties out, producing a per-cell RANK (0..N-1) over a 32x32 tile: thresholding rank against any coverage level yields a blue-noise-distributed dot set. This is a different point-generation algorithm from the repo's ordered-dither family (background-ascii-dither's Bayer mode, the chart-*-dither components), which threshold against a small regular 4x4 matrix and read as a visible crosshatch lattice at any zoom — void-and-cluster's aperiodic, non-lattice point set reads as fine isotropic grain instead, the distinction the dithering literature draws between ordered and blue-noise dither families. TEMPORAL layer: every rAF tick advances a frame offset by the golden-ratio conjugate mod 1 and thresholds frac(rank/N + offset) < coverage, so every cell cycles through the full blue-noise rank order over N frames, decorrelated frame to frame — the animated-noise / golden-ratio-sequence technique real-time renderers use for TAA dithering, an honest approximation of full spatiotemporal blue noise (which needs a pre-baked 3D noise volume) built from one 2D mask rather than a from-scratch spatiotemporal solve. A scrim has a job: content sits on top of it and must stay readable. The canvas paints a controlled flat --background wash (alpha 0.55) as the base dim, with the blue-noise grain layered on top at a fine cellSize (default 4px) and low alpha (0.5) in --foreground — texture on top of legibility, not the source of it; the overlay panel itself stays fully opaque so grain never touches its text regardless of coverage. Coverage defaults to 0.07 (7% of cells lit at any instant) so the mean shimmer stays subtle. Token colors (--background, --foreground) are read via getComputedStyle(document.documentElement) inside useLayoutEffect, before first paint, and re-read on a MutationObserver watching documentElement's class — no color field is ever used before that read. prefers-reduced-motion freezes to a single static blue-noise mask instance (frame offset 0) rather than reshuffling every frame: still aperiodic and still distinct from Bayer, just not animated, since a per-frame reshuffling grain is exactly the motion this media query exists to suppress. ResizeObserver (debounced 100ms) drives cols/rows so the grain cell size stays fine and proportional across viewport sizes rather than becoming a coarse checkerboard in a small container; the render loop pauses on document visibilitychange. The canvas itself is aria-hidden and pointer-events:none — purely decorative scrim texture; the demo's surrounding dialog owns real overlay semantics (role=dialog, aria-modal, Escape closes, Tab trapped on its single input). Props: cellSize (default 4), coverage (default 0.07), className."
  },
  "type": "registry:ui"
}