{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "latticework",
  "type": "registry:ui",
  "title": "Latticework",
  "description": "An interactive dot grid: cool-steel silver dots heat white → cyan as the cursor approaches, with a configurable falloff curve; a click or tap sends a hammer-strike shockwave ring through the grid. Pauses offscreen. Freezes to a static, gently lit frame under prefers-reduced-motion. Strikes stack, a `strike()` ref handle lands them programmatically, and a ghost pointer warms the grid when untouched.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/latticework/latticework.tsx",
      "type": "registry:ui",
      "target": "components/crucible/latticework.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport type LatticeworkFalloff = \"linear\" | \"exponential\" | \"gaussian\";\n\n/** Imperative verbs exposed through the `ref` prop. */\nexport interface LatticeworkHandle {\n  /**\n   * Land a hammer-strike at `(x, y)` — fractions of the element, `0,0`\n   * top-left. Defaults to the center. Calls stack (up to six rings in flight).\n   * No-op while paused, under reduced motion, or when `strikeStrength` is 0.\n   */\n  strike: (x?: number, y?: number) => void;\n}\n\nexport interface LatticeworkProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Imperative handle — see {@link LatticeworkHandle}. */\n  ref?: React.Ref<LatticeworkHandle>;\n  /** Spacing between dots in px. Default 34. */\n  spacing?: number;\n  /** Resting dot radius in px. Default 1.4. */\n  dotRadius?: number;\n  /** Proximity radius in px within which dots warm up. Default 180. */\n  radius?: number;\n  /** Shape of the heat falloff curve across the proximity radius. Default \"gaussian\". */\n  falloff?: LatticeworkFalloff;\n  /** Cold → mid → hot color ramp. Defaults to steel silver → white → cyan. */\n  colors?: [string, string, string];\n  /** How quickly dots ease toward their target heat, 0–1 per frame at 60fps. Default 0.12. */\n  responsiveness?: number;\n  /** devicePixelRatio ceiling. Default 2. */\n  dpr?: number;\n  /**\n   * Pointer-down \"hammer-strike\" strength: an expanding ring of displacement\n   * and temporary heat radiates from the strike point. 0 disables strikes.\n   * On coarse pointers a tap lands one strike. Default 1.\n   */\n  strikeStrength?: number;\n  /**\n   * Freeze the field — pointer heat and strikes stop and it settles on the\n   * same static, gently lit resting frame used under reduced motion.\n   */\n  paused?: boolean;\n  /**\n   * Idle autoplay: after ~1.5s with no real pointer, a seeded ghost pointer\n   * drifts over the grid on a slow Lissajous path, warming the dots it passes\n   * and landing an occasional strike, so the field never looks dead in\n   * thumbnails and heroes. The real pointer takes over instantly. Never runs\n   * under reduced motion. Default true.\n   */\n  idle?: boolean;\n}\n\ninterface Strike {\n  x: number;\n  y: number;\n  born: number;\n}\n\n/** Lifetime of one strike ring, seconds. */\nconst STRIKE_LIFE = 1.3;\n/** Most strike rings in flight at once. */\nconst MAX_STRIKES = 6;\n/** Ghost takes over this long (ms) after the last real pointer activity. */\nconst GHOST_DELAY_MS = 1500;\n/** Ghost heat relative to a real pointer — present, but deliberate. */\nconst GHOST_PRESENCE = 0.75;\n\ninterface Internals {\n  ctx: CanvasRenderingContext2D;\n  cols: number;\n  rows: number;\n  heat: Float32Array;\n  pointer: [number, number] | null;\n  /** Idle ghost pointer (device px), used only while `pointer` is null. */\n  ghost: [number, number];\n  /** Eased 0–1 ghost presence. */\n  ghostAmt: number;\n  nextGhostStrike: number;\n  strikes: Strike[];\n  time: number;\n  width: number;\n  height: number;\n  spacing: number;\n  dotRadius: number;\n  radius: number;\n  falloff: LatticeworkFalloff;\n  cold: [number, number, number];\n  mid: [number, number, number];\n  hot: [number, number, number];\n  responsiveness: number;\n  strikeStrength: number;\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  let h = hex.replace(\"#\", \"\").trim();\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  const int = parseInt(h, 16);\n  if (Number.isNaN(int) || h.length !== 6) return [82, 82, 91];\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\n\nfunction falloffCurve(t: number, kind: LatticeworkFalloff): number {\n  // t is normalized distance, 0 = on top of the dot, 1 = at the radius edge.\n  const c = Math.min(Math.max(1 - t, 0), 1);\n  switch (kind) {\n    case \"linear\":\n      return c;\n    case \"exponential\":\n      return c * c;\n    case \"gaussian\":\n    default:\n      return Math.exp(-(t * t) * 4);\n  }\n}\n\nfunction mixRgb(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {\n  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\n}\n\nfunction rampColor(\n  heat: number,\n  cold: [number, number, number],\n  mid: [number, number, number],\n  hot: [number, number, number]\n): [number, number, number] {\n  if (heat <= 0.5) return mixRgb(cold, mid, heat * 2);\n  return mixRgb(mid, hot, (heat - 0.5) * 2);\n}\n\nfunction layoutGrid(it: Internals, width: number, height: number, spacing: number) {\n  it.width = width;\n  it.height = height;\n  it.spacing = spacing;\n  it.cols = Math.max(1, Math.floor(width / spacing) + 1);\n  it.rows = Math.max(1, Math.floor(height / spacing) + 1);\n  it.heat = new Float32Array(it.cols * it.rows);\n}\n\n/** Baseline diagonal lift so the resting field never reads as a black void. */\nfunction restingGradient(rx: number, ry: number, cols: number, rows: number): number {\n  return Math.max(0, 1 - (Math.abs(rx / cols - 0.5) + Math.abs(ry / rows - 0.5)) * 1.1);\n}\n\nfunction draw(it: Internals, staticFrame: boolean, dt: number) {\n  const { ctx, cols, rows, spacing, dotRadius, radius, pointer, strikes } = it;\n  ctx.clearRect(0, 0, it.width, it.height);\n\n  // Prune spent strike rings before the dot pass.\n  if (strikes.length > 0) {\n    for (let i = strikes.length - 1; i >= 0; i--) {\n      if (it.time - strikes[i].born > STRIKE_LIFE) strikes.splice(i, 1);\n    }\n  }\n  const ringSpeed = radius * 3.2; // px/s — the hammer wave front\n  const ringWidth = Math.max(radius * 0.32, spacing * 1.4);\n\n  for (let ry = 0; ry < rows; ry++) {\n    for (let rx = 0; rx < cols; rx++) {\n      const idx = ry * cols + rx;\n      const x = rx * spacing;\n      const y = ry * spacing;\n\n      let target = 0;\n      if (staticFrame) {\n        // Aesthetic resting frame: a gentle diagonal silver-to-white gradient\n        // across the field instead of a flat, lifeless grid of identical dots.\n        target = restingGradient(rx, ry, cols, rows) * 0.35;\n      } else {\n        // Live idle floor: a fainter version of the same gradient, so the\n        // stage never reads empty before the pointer arrives.\n        target = restingGradient(rx, ry, cols, rows) * 0.16;\n        const src = pointer ?? (it.ghostAmt > 0.01 ? it.ghost : null);\n        if (src) {\n          const gain = pointer ? 1 : it.ghostAmt * GHOST_PRESENCE;\n          const dx = x - src[0];\n          const dy = y - src[1];\n          const dist = Math.sqrt(dx * dx + dy * dy);\n          if (dist <= radius) {\n            target = Math.max(target, falloffCurve(dist / radius, it.falloff) * gain);\n          }\n        }\n      }\n\n      const prev = it.heat[idx];\n      const ease = staticFrame ? 1 : 1 - Math.pow(1 - it.responsiveness, Math.max(dt, 1) / 16.7);\n      let next = prev + (target - prev) * ease;\n\n      // Hammer-strike rings: an expanding wave front displaces each dot\n      // radially as it passes and leaves temporary heat behind (which then\n      // cools through the same easing as pointer warmth).\n      let ox = 0;\n      let oy = 0;\n      if (!staticFrame && strikes.length > 0 && it.strikeStrength > 0) {\n        for (let si = 0; si < strikes.length; si++) {\n          const s = strikes[si];\n          const age = it.time - s.born;\n          if (age < 0) continue;\n          const decay = Math.max(1 - age / STRIKE_LIFE, 0);\n          const ringR = age * ringSpeed;\n          const dx = x - s.x;\n          const dy = y - s.y;\n          const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n          const rel = (dist - ringR) / ringWidth;\n          const band = Math.exp(-rel * rel);\n          if (band < 0.01) continue;\n          const amt = band * decay * it.strikeStrength;\n          next = Math.min(next + amt * 0.85, 1);\n          ox += (dx / dist) * amt * spacing * 0.45;\n          oy += (dy / dist) * amt * spacing * 0.45;\n        }\n      }\n      it.heat[idx] = next;\n\n      const [r, g, b] = rampColor(next, it.cold, it.mid, it.hot);\n      const radiusPx = dotRadius + next * dotRadius * 1.8;\n      const alpha = 0.5 + next * 0.5;\n\n      if (next > 0.03) {\n        ctx.save();\n        ctx.shadowColor = `rgba(${r}, ${g}, ${b}, ${Math.min(next, 1)})`;\n        ctx.shadowBlur = 10 * next;\n        ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;\n        ctx.beginPath();\n        ctx.arc(x + ox, y + oy, radiusPx, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.restore();\n      } else {\n        ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.5)`;\n        ctx.beginPath();\n        ctx.arc(x + ox, y + oy, dotRadius, 0, Math.PI * 2);\n        ctx.fill();\n      }\n    }\n  }\n}\n\n/**\n * Latticework — a cool-steel dot grid, flat at rest, whose neutral silver\n * dots heat toward white and then cyan as the cursor approaches, with a\n * configurable falloff curve for how sharply the glow drops off with\n * distance. Pointer-down strikes the field: an expanding ring of\n * displacement and temporary glow radiates from the strike point (several\n * strikes can coexist). Under reduced motion / paused it renders one static,\n * gently lit diagonal frame instead of a flat, lifeless grid — and takes\n * no strikes.\n */\nexport function Latticework({\n  spacing = 34,\n  dotRadius = 1.4,\n  radius = 180,\n  falloff = \"gaussian\",\n  colors = [\"#52525b\", \"#e4e4e7\", \"#22d3ee\"],\n  responsiveness = 0.12,\n  dpr = 2,\n  strikeStrength = 1,\n  paused = false,\n  idle = true,\n  ref,\n  className,\n  ...props\n}: LatticeworkProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n  const staticFrame = paused || reducedMotion;\n  const staticFrameRef = React.useRef(staticFrame);\n  staticFrameRef.current = staticFrame;\n  const lastElapsedRef = React.useRef(0);\n  const idleRef = React.useRef(idle);\n  idleRef.current = idle;\n  // performance.now() of the last real pointer activity; -Infinity = never.\n  const lastRealPointerRef = React.useRef(-Infinity);\n\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      const it = internalsRef.current;\n      if (!it) return;\n      // useVisibilityPause restarts `elapsed` at 0 whenever its `paused` option\n      // flips; re-seat so the first frame after a resume steps by 0, not backwards.\n      if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;\n      const dt = (elapsed - lastElapsedRef.current) * 1000;\n      lastElapsedRef.current = elapsed;\n      it.time = elapsed;\n      // Idle ghost: slow seeded Lissajous drift + an occasional strike.\n      const ghostOn =\n        idleRef.current &&\n        !it.pointer &&\n        performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS;\n      it.ghostAmt += ((ghostOn ? 1 : 0) - it.ghostAmt) * Math.min(1, dt / 600);\n      if (ghostOn) {\n        const t = elapsed;\n        it.ghost[0] = it.width * (0.5 + 0.36 * Math.sin(t * 0.37) * Math.cos(t * 0.113));\n        it.ghost[1] = it.height * (0.5 + 0.3 * Math.sin(t * 0.29 + 1.3));\n        if (elapsed >= it.nextGhostStrike) {\n          if (it.strikeStrength > 0 && it.ghostAmt > 0.5) {\n            it.strikes.push({ x: it.ghost[0], y: it.ghost[1], born: elapsed });\n            if (it.strikes.length > MAX_STRIKES) it.strikes.shift();\n          }\n          // Deterministic, slightly irregular cadence (~4.5–6.5s).\n          it.nextGhostStrike = elapsed + 4.5 + 2 * (0.5 + 0.5 * Math.sin(elapsed * 12.9898));\n        }\n      } else if (it.nextGhostStrike < elapsed + 1.2) {\n        // Ghost's first strike after it takes over lands a beat later.\n        it.nextGhostStrike = elapsed + 1.2;\n      }\n      draw(it, false, dt);\n    },\n    []\n  );\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const effectiveDpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, dpr);\n\n    const it: Internals = {\n      ctx,\n      cols: 0,\n      rows: 0,\n      heat: new Float32Array(0),\n      pointer: null,\n      ghost: [0, 0],\n      ghostAmt: 0,\n      nextGhostStrike: 0,\n      strikes: [],\n      time: lastElapsedRef.current,\n      width: 0,\n      height: 0,\n      spacing: spacing * effectiveDpr,\n      dotRadius: dotRadius * effectiveDpr,\n      radius: radius * effectiveDpr,\n      falloff,\n      cold: hexToRgb(colors[0]),\n      mid: hexToRgb(colors[1]),\n      hot: hexToRgb(colors[2]),\n      responsiveness,\n      strikeStrength: Math.max(strikeStrength, 0),\n    };\n    internalsRef.current = it;\n\n    const resize = () => {\n      const w = Math.max(container.clientWidth, 1);\n      const h = Math.max(container.clientHeight, 1);\n      canvas.width = Math.round(w * effectiveDpr);\n      canvas.height = Math.round(h * effectiveDpr);\n      canvas.style.width = \"100%\";\n      canvas.style.height = \"100%\";\n      layoutGrid(it, canvas.width, canvas.height, spacing * effectiveDpr);\n      draw(it, staticFrameRef.current, 0);\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(container);\n    resize();\n\n    const onPointerMove = (e: PointerEvent) => {\n      const r = container.getBoundingClientRect();\n      it.pointer = [(e.clientX - r.left) * effectiveDpr, (e.clientY - r.top) * effectiveDpr];\n      lastRealPointerRef.current = performance.now();\n    };\n    const onPointerLeave = () => {\n      it.pointer = null;\n      // The ghost resumes after the usual delay rather than snapping in.\n      lastRealPointerRef.current = performance.now();\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      // Hammer-strike: one expanding ring per pointer-down (a tap on coarse\n      // pointers counts). No rings under reduced motion / paused.\n      if (staticFrameRef.current || it.strikeStrength <= 0) return;\n      lastRealPointerRef.current = performance.now();\n      const r = container.getBoundingClientRect();\n      it.strikes.push({\n        x: (e.clientX - r.left) * effectiveDpr,\n        y: (e.clientY - r.top) * effectiveDpr,\n        born: it.time,\n      });\n      if (it.strikes.length > MAX_STRIKES) it.strikes.shift();\n    };\n    container.addEventListener(\"pointermove\", onPointerMove);\n    container.addEventListener(\"pointerleave\", onPointerLeave);\n    container.addEventListener(\"pointerdown\", onPointerDown);\n\n    return () => {\n      ro.disconnect();\n      container.removeEventListener(\"pointermove\", onPointerMove);\n      container.removeEventListener(\"pointerleave\", onPointerLeave);\n      container.removeEventListener(\"pointerdown\", onPointerDown);\n      internalsRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [dpr, spacing, dotRadius, radius, falloff, colors.join(\",\"), responsiveness, strikeStrength]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      strike: (x = 0.5, y = 0.5) => {\n        const it = internalsRef.current;\n        if (!it || staticFrameRef.current || it.strikeStrength <= 0) return;\n        it.strikes.push({\n          x: Math.min(Math.max(x, 0), 1) * it.width,\n          y: Math.min(Math.max(y, 0), 1) * it.height,\n          born: it.time,\n        });\n        if (it.strikes.length > MAX_STRIKES) it.strikes.shift();\n      },\n    }),\n    []\n  );\n\n  // Re-render the frozen frame whenever paused/reduced-motion state changes.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it) return;\n    it.pointer = null;\n    it.strikes.length = 0;\n    draw(it, staticFrame, 0);\n  }, [staticFrame]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"latticework\"\n      className={cn(\"absolute inset-0 -z-10 overflow-hidden\", className)}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block h-full w-full\" />\n    </div>\n  );\n}\n"
    }
  ]
}