{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-ascii-terrain",
  "title": "Hero ASCII Terrain",
  "description": "A full-bleed ASCII landscape hero — five ridgelines of deterministic value noise recede toward a horizon, each layer stepping up in frequency, height and ink density the nearer it sits, with the pointer driving parallax so the terrain slides past like a window on a moving vehicle.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/hero-ascii-terrain/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ScarpHorizon — a full-bleed ASCII landscape hero. Five ridgelines recede\n// toward a fixed horizon row, each a deterministic 1D value-noise curve (two\n// octaves, fixed hash seed per layer — the terrain never reshuffles on\n// remount) sampled at an ever-larger spatial frequency and amplitude going\n// from farthest to nearest, so distant ridges read as smooth low rolling haze\n// and the nearest ridge reads as jagged and dense. Every column is resolved\n// far-to-near into one Uint8Array \"which layer wins this cell\" buffer before\n// a single render pass — that overwrite-in-draw-order is what gives clean\n// occlusion (a nearer ridge silhouette blotting out a farther one) instead of\n// two glyphs stacked translucently in the same cell. Layer opacity and ramp\n// character both step up with proximity (haze -> ink). A sparse fixed star\n// field fills the sky above the horizon, each star's alpha breathing on a\n// slow sine so the sky isn't a dead flat void. The pointer drives parallax —\n// each layer's horizontal (and slightly vertical) sample offset is scaled by\n// its own proximity factor, eased, plus a slow idle drift so the scene keeps\n// a pulse even at rest — so panning across the field reads like a window\n// gliding past terrain, nearer ridges sliding faster than the horizon.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\"; // 10-step density ramp, index 0 = blank\n\ninterface Layer {\n  freq: number; // spatial frequency in noise-space, per column\n  baseFrac: number; // fraction of terrain band the ridge's average sits at\n  ampFrac: number; // fraction of terrain band the ridge swings by\n  parallax: number; // 0..1, how much this layer answers to the pointer\n  drift: number; // idle ambient pan, in GRID COLUMNS/s (not noise-space)\n  alpha: number; // resting opacity, haze (far) -> ink (near)\n  charIdx: number; // index into RAMP\n  seedA: number;\n  seedB: number;\n}\n\n// far -> near: frequency, base depth and amplitude all step up together —\n// the nearest ridge is the tallest, most jagged, and reaches lowest in frame.\n// Amplitude is large relative to the gap between layers on purpose: a ridge\n// silhouette only reads as terrain if it has real peaks and valleys, not a\n// nearly flat line with a haze tint.\n//\n// `drift` used to be one global constant (IDLE_DRIFT) multiplied through\n// `parallax`, same as the pointer offset. That reads as \"barely moving\" no\n// matter how large the constant gets: the ridge only repaints when the\n// sampled noise crosses into a different grid COLUMN, and with parallax\n// spanning 0.05 -> 0.95 the resulting pace was ~1 column every 125s on the\n// farthest layer and ~1 column every 6.6s even on the nearest — both well\n// below what a glance registers, confirmed at 0% of cells changing per\n// second measured over a 6s sample at rest. `drift` is now authored directly\n// in columns/second, decoupled from `parallax` (which still governs only the\n// pointer response): the near layer crosses a column every ~0.3s, the far\n// layer keeps drifting slowly rather than being effectively frozen.\nconst LAYERS: Layer[] = [\n  { freq: 0.012, baseFrac: 0.0, ampFrac: 0.11, parallax: 0.05, drift: 0.45, alpha: 0.24, charIdx: 1, seedA: 11, seedB: 511 },\n  { freq: 0.018, baseFrac: 0.14, ampFrac: 0.16, parallax: 0.18, drift: 0.9, alpha: 0.44, charIdx: 3, seedA: 23, seedB: 727 },\n  { freq: 0.026, baseFrac: 0.32, ampFrac: 0.2, parallax: 0.38, drift: 1.7, alpha: 0.64, charIdx: 5, seedA: 47, seedB: 941 },\n  { freq: 0.038, baseFrac: 0.52, ampFrac: 0.24, parallax: 0.62, drift: 2.8, alpha: 0.84, charIdx: 7, seedA: 71, seedB: 1153 },\n  { freq: 0.055, baseFrac: 0.74, ampFrac: 0.27, parallax: 0.95, drift: 4.5, alpha: 1, charIdx: 9, seedA: 97, seedB: 1381 },\n];\n\nconst STAR_CHARS = [1, 2, 3]; // RAMP indices used for stars\nconst CURSOR_EASE = 0.08;\nconst MAX_PARALLAX_COLS = 46; // world-unit shift at full pointer travel\nconst MAX_PARALLAX_ROWS = 5;\nconst DT_MAX = 0.05;\n\n// deterministic hash -> [0,1); fixed seed means the terrain is the same\n// shape every mount, never reshuffled\nfunction hash1(n: number, seed: number): number {\n  const x = Math.sin(n * 127.1 + seed * 311.7) * 43758.5453;\n  return x - Math.floor(x);\n}\n\nfunction noise1D(x: number, seed: number): number {\n  const i0 = Math.floor(x);\n  const i1 = i0 + 1;\n  const t = x - i0;\n  const s = t * t * (3 - 2 * t);\n  const a = hash1(i0, seed);\n  const b = hash1(i1, seed);\n  return a + (b - a) * s;\n}\n\n// two octaves, weighted 0.7/0.3, mapped to -1..1\nfunction ridgeNoise(x: number, seedA: number, seedB: number): number {\n  const n = noise1D(x, seedA) * 0.7 + noise1D(x * 2.7 + 11.3, seedB) * 0.3;\n  return n * 2 - 1;\n}\n\nfunction mulberry32(a: number) {\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nexport interface ScarpHorizonProps {\n  /** grid cell size in px */\n  cellSize?: number;\n  /** headline / CTA rendered over the field */\n  children?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function ScarpHorizon({\n  cellSize = 13,\n  children,\n  className = \"\",\n}: ScarpHorizonProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !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 fg = \"currentColor\";\n    let muted = \"currentColor\";\n    let cellW = cellSize;\n    let cellH = cellSize;\n    let cols = 0;\n    let rows = 0;\n    let dpr = 1;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n\n    let horizonRow = 0;\n    let terrainRows = 0;\n\n    let grpBuf = new Int8Array(0); // -1 = sky/blank, 0..LAYERS.length-1 = layer\n    let charBuf = new Uint8Array(0);\n\n    let starCol = new Float32Array(0);\n    let starRow = new Float32Array(0);\n    let starChar = new Uint8Array(0);\n    let starPhase = new Float32Array(0);\n    let starAlpha = new Float32Array(0);\n    let starCount = 0;\n\n    const readTokens = () => {\n      fg = getComputedStyle(canvas).color;\n      muted =\n        getComputedStyle(document.documentElement)\n          .getPropertyValue(\"--ns-muted\")\n          .trim() || fg;\n    };\n\n    const measureCell = (fontFamily: string) => {\n      const off = document.createElement(\"canvas\");\n      const octx = off.getContext(\"2d\");\n      if (!octx) return;\n      octx.font = `${cellSize}px ${fontFamily}`;\n      cellW = Math.max(4, octx.measureText(\"MMMMMMMMMM\").width / 10);\n      cellH = cellSize;\n    };\n\n    const buildStars = () => {\n      const skyRows = horizonRow;\n      const rand = mulberry32(0xa57e0);\n      starCount = Math.min(240, Math.max(0, Math.floor(cols * skyRows * 0.035)));\n      starCol = new Float32Array(starCount);\n      starRow = new Float32Array(starCount);\n      starChar = new Uint8Array(starCount);\n      starPhase = new Float32Array(starCount);\n      starAlpha = new Float32Array(starCount);\n      for (let i = 0; i < starCount; i++) {\n        starCol[i] = Math.floor(rand() * cols);\n        starRow[i] = Math.floor(rand() * Math.max(1, skyRows - 1));\n        starChar[i] = STAR_CHARS[Math.floor(rand() * STAR_CHARS.length)];\n        starPhase[i] = rand() * Math.PI * 2;\n        starAlpha[i] = 0.25 + rand() * 0.4;\n      }\n    };\n\n    const resize = () => {\n      const { width, height } = canvas.getBoundingClientRect();\n      if (width < 2 || height < 2) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const fontFamily = getComputedStyle(canvas).fontFamily;\n      measureCell(fontFamily);\n      ctx.font = `${cellSize}px ${fontFamily}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n\n      // `ceil`, not `floor`: the grid is `rows * cellH` tall, so flooring left\n      // an unpainted strip up to one cell high along the bottom edge — read as\n      // a stray band of padding under the terrain. Overdrawing by a partial\n      // row instead costs nothing, because the root clips with\n      // `overflow-hidden`. Same for columns and the right edge.\n      cols = Math.max(8, Math.ceil(width / cellW));\n      rows = Math.max(10, Math.ceil(height / cellH));\n      horizonRow = Math.max(2, Math.floor(rows * 0.34));\n      terrainRows = Math.max(1, rows - horizonRow);\n\n      grpBuf = new Int8Array(cols * rows);\n      charBuf = new Uint8Array(cols * rows);\n      buildStars();\n      sized = true;\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) draw(0, 0, 0);\n      }, 150);\n    };\n\n    const draw = (t: number, offX: number, offY: number) => {\n      if (!sized) return;\n      const w = cols * cellW;\n      const h = rows * cellH;\n      ctx.clearRect(0, 0, w, h);\n      grpBuf.fill(-1);\n      charBuf.fill(0);\n\n      // -- terrain: far -> near, each layer overwrites the cells it covers --\n      for (let li = 0; li < LAYERS.length; li++) {\n        const layer = LAYERS[li];\n        // pointer parallax and idle drift are two different units on\n        // purpose: pointer offset is already in grid columns (scaled by\n        // proximity), idle drift is authored directly in columns/s per\n        // layer — see the comment on `LAYERS` for why they used to share\n        // one constant and why that read as motionless.\n        const worldOffX = offX * layer.parallax + t * layer.drift;\n        const worldOffY = offY * layer.parallax * 0.6;\n        for (let c = 0; c < cols; c++) {\n          const nx = (c + worldOffX) * layer.freq;\n          const n = ridgeNoise(nx, layer.seedA, layer.seedB);\n          let ridge =\n            horizonRow +\n            layer.baseFrac * terrainRows +\n            n * layer.ampFrac * terrainRows -\n            worldOffY;\n          ridge = Math.max(horizonRow, Math.min(rows - 1, Math.round(ridge)));\n          // flat body at the layer's own density — no internal gradient — so\n          // the boundary against the (lighter) layer behind it stays a hard,\n          // legible edge instead of blurring into a continuous field\n          for (let r = ridge; r < rows; r++) {\n            const idx = r * cols + c;\n            grpBuf[idx] = li;\n            charBuf[idx] = layer.charIdx;\n          }\n          // one-row crest highlight, denser than the layer's own body, so the\n          // silhouette line itself reads as a distinct rim against the haze\n          const crestIdx = ridge * cols + c;\n          charBuf[crestIdx] = Math.min(RAMP.length - 1, layer.charIdx + 1);\n        }\n      }\n\n      // -- sky: sparse breathing stars, drawn directly (no grid buffer) -----\n      ctx.fillStyle = muted;\n      for (let i = 0; i < starCount; i++) {\n        const twinkle = 0.5 + 0.5 * Math.sin(t * 1.1 + starPhase[i]);\n        ctx.globalAlpha = starAlpha[i] * twinkle;\n        ctx.fillText(\n          RAMP[starChar[i]],\n          starCol[i] * cellW + cellW / 2,\n          starRow[i] * cellH + cellH / 2\n        );\n      }\n\n      // -- terrain render: one pass per layer, one globalAlpha set each -----\n      ctx.fillStyle = fg;\n      for (let li = 0; li < LAYERS.length; li++) {\n        const layer = LAYERS[li];\n        ctx.globalAlpha = layer.alpha;\n        for (let r = horizonRow; r < rows; r++) {\n          for (let c = 0; c < cols; c++) {\n            const idx = r * cols + c;\n            if (grpBuf[idx] !== li) continue;\n            ctx.fillText(\n              RAMP[charBuf[idx]],\n              c * cellW + cellW / 2,\n              r * cellH + cellH / 2\n            );\n          }\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // -- hot-path state: locals only, never React state ---------------------\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const cursor = { tx: 0, ty: 0, x: 0, y: 0 };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      cursor.x += (cursor.tx - cursor.x) * CURSOR_EASE;\n      cursor.y += (cursor.ty - cursor.y) * CURSOR_EASE;\n      draw(t, cursor.x, cursor.y);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      const nx = (e.clientX - rect.left) / rect.width - 0.5; // -0.5..0.5\n      const ny = (e.clientY - rect.top) / rect.height - 0.5;\n      cursor.tx = nx * MAX_PARALLAX_COLS;\n      cursor.ty = ny * MAX_PARALLAX_ROWS;\n    };\n    const onPointerLeave = () => {\n      cursor.tx = 0;\n      cursor.ty = 0;\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced && ready) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw(0, 0, 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      readTokens();\n      resize();\n      ready = true;\n      if (reduced) {\n        draw(0, 0, 0);\n      } else {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    window.addEventListener(\"resize\", onResize);\n    if (!reduced) {\n      root.addEventListener(\"pointermove\", onPointerMove);\n      root.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [cellSize]);\n\n  // `h-full` matters as much as the min-height below: a min-height only sets a\n  // FLOOR. Dropped into a stretched grid/flex item taller than that floor — an\n  // auth panel whose other column carries more copy — the root stopped at its\n  // min-height and the canvas ended partway down, leaving a band of dead\n  // background under the terrain. `h-full` resolves to `auto` when the parent\n  // has no definite height (so the min-height still governs a standalone\n  // hero) and fills the parent when it does.\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate h-full w-full overflow-hidden bg-background font-mono ${\n        /\\bmin-h-/.test(className) ? \"\" : \"min-h-screen\"\n      } ${className}`}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 block h-full w-full text-foreground\" />\n      {children ? (\n        <div className=\"relative z-10 flex h-full w-full flex-col items-start justify-end gap-4 p-8 sm:p-14\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hero-ascii-terrain.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "ascii",
      "hero",
      "background",
      "canvas",
      "cursor",
      "noise",
      "terrain"
    ],
    "instruction": "Build a full-bleed Canvas 2D hero: five ridgeline layers, far to near, each a deterministic 1D value-noise curve (two octaves at 0.7/0.3 weight, hashed with Math.sin-based fixed seeds per layer — never Math.random, so the terrain is identical every mount) sampled once per column at a per-layer spatial frequency, base depth and amplitude that all step up together with proximity (0.02 to 0.13 frequency, 0.04 to 0.7 base fraction of the terrain band, 0.05 to 0.16 amplitude fraction) so the farthest ridge is a smooth low haze near the horizon and the nearest is tall and jagged, reaching furthest into the frame. Layers are resolved far-to-near into one Int8Array 'which layer owns this cell' buffer and one Uint8Array ramp-index buffer, cleared and rebuilt every frame — later (nearer) layers simply overwrite the cells they cover, which is what gives clean occlusion instead of two glyphs stacked translucently in one cell. A render pass then walks the terrain band once per layer (one ctx.globalAlpha set per layer, stepping 0.26 to 1.0 opacity from haze to ink) drawing only the cells that layer owns, with the ramp character (' .:-=+*#%@' index 2,3,5,7,9) also stepping denser with proximity. Above the horizon row (fixed at 34% of grid height, ridges clamped so no peak ever crosses into the sky), a fixed-seed sparse star field (~3.5% of sky cells, capped at 240) is drawn directly per star — not through the buffer, since stars need no occlusion — each with an independent alpha 'twinkle' on a slow sine so the sky isn't a dead flat void. PARALLAX: the pointer's position inside the container, normalized to -0.5..0.5 and eased (0.08/frame lerp), maps to a world-column and world-row offset (max 46 columns, 5 rows at full travel) that each layer's noise sample is displaced by, scaled by that layer's own 0..1 proximity factor (0.05 farthest to 0.92 nearest) — so nearer ridges visibly slide faster than the horizon as the cursor moves, exactly like looking out of a moving window. A slow ambient drift (0.045 world-units/s, same per-layer scaling) keeps the terrain gently alive even with the pointer at rest. Direct-DOM rAF loop, zero React state on the hot path; the ramp/group buffers and star typed arrays are allocated once per resize and reused every frame. The mono cell is measured via an offscreen canvas's measureText after document.fonts.ready (a fallback-font measurement bakes in the wrong grid aspect until reload). Glyph ink reads getComputedStyle(canvas).color for terrain and the --ns-muted token for stars, both re-derived on a documentElement class MutationObserver for live theme flips. prefers-reduced-motion renders exactly one static frame at t=0 with the pointer offset and idle drift both zeroed (the full ridge shape, not an edge-on or empty state) and skips the rAF loop and pointer listeners entirely. Optional children render over the field, bottom-left anchored with padding, so the hero can carry a real headline and CTA. Props: cellSize (grid cell px, default 13), children, className.",
    "rank": 7
  },
  "type": "registry:ui"
}