{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waveform-ascii-scrub",
  "title": "Waveform ASCII Scrub",
  "description": "An audio-style waveform rendered as columns of ASCII density glyphs with a draggable playhead: dragging (or hovering, or holding keyboard focus) subdivides the glyph columns nearest the cursor into finer sub-columns that re-ink the region at higher resolution, while the rest of the strip stays coarse.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/waveform-ascii-scrub/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// WaveformAsciiScrub — an audio-style waveform rendered as columns of ASCII\n// density glyphs, with a draggable playhead. The mechanic that sets this\n// apart from every neighboring instrument: resolution is spatial and local,\n// not a global hover-brighten. At rest the waveform renders at a fixed\n// coarse column count (one glyph column per base sample). Dragging the\n// playhead (or just hovering, or holding keyboard focus) subdivides the few\n// base columns nearest the pointer into several finer sub-columns, each\n// independently re-sampling the underlying (locally synthesised, no audio\n// API) amplitude function at its own narrower time span — so the region\n// under the cursor is re-inked at genuinely finer detail (more, thinner\n// glyph columns revealing sub-peaks the coarse column averaged away) while\n// the rest of the strip stays exactly as coarse as it was. The radius eases\n// in/out over ~250ms rather than snapping.\n//\n// Distinct from voice-recorder-meter (real Web Audio analyser bars, one bin\n// per bar, no drag/scrub, no resolution concept at all), scrubber-film-strip\n// (a physical claw-and-perforation playback scrubber — its mechanic is\n// slow-drag-snaps/fast-drag-glides, not spatial resolution), sparkline-ascii\n// (a DOM character grid, fixed resolution throughout, no drag), and\n// histogram-live-grain (distribution of arrivals, not a scrub position).\n// ---------------------------------------------------------------------------\n\nexport interface WaveformAsciiScrubProps {\n  /** Controlled scrub position, 0..1. Omit for uncontrolled (see defaultValue). */\n  value?: number;\n  /** Initial position, 0..1, when uncontrolled. Default 0.3. */\n  defaultValue?: number;\n  /** Total duration in seconds, used only to format aria-valuetext. Default 180. */\n  duration?: number;\n  /** called with the new position on every scrub */\n  onValueChange?: (value: number) => void;\n  /** accessible name for the scrubber */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst BASE_COLS = 64;\nconst SUBDIVIDE = 3;\nconst FOCUS_RADIUS_COLS = 6;\nconst MAX_ROWS = 7;\nconst CHARSET = \" .:-=+*#%@\";\nconst VIEW_W = 600;\nconst VIEW_H = 150;\nconst EASE_EPS = 0.02;\n\n// Deterministic synthetic amplitude — layered sine partials plus a slow\n// swell envelope, so a coarse sample averages away detail that a finer\n// sample genuinely reveals. No audio API, no randomness.\nfunction amplitude(t: number): number {\n  const s = t * 40;\n  let v = 0;\n  v += 0.5 * Math.sin(s * 2.13 + 1.7);\n  v += 0.3 * Math.sin(s * 5.02 + 0.4);\n  v += 0.15 * Math.sin(s * 11.7 + 2.9);\n  v += 0.1 * Math.sin(s * 23.3 + 5.1);\n  const swell = 0.55 + 0.45 * Math.pow(Math.sin(t * Math.PI * 2 * 1.3 + 0.6), 2);\n  return Math.min(1, Math.abs(v) * swell);\n}\n\nfunction peakIn(t0: number, t1: number): number {\n  const SAMPLES = 6;\n  let peak = 0;\n  for (let s = 0; s < SAMPLES; s++) {\n    const t = t0 + ((t1 - t0) * (s + 0.5)) / SAMPLES;\n    peak = Math.max(peak, amplitude(t));\n  }\n  return peak;\n}\n\nfunction formatTime(frac: number, duration: number): string {\n  const secs = Math.round(Math.max(0, Math.min(1, frac)) * duration);\n  const m = Math.floor(secs / 60);\n  const s = secs % 60;\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  muted: string;\n  border: string;\n  accent: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#171717\"),\n    bg: get(\"--background\", \"#ffffff\"),\n    muted: get(\"--ns-muted\", \"#4d4d4d\"),\n    border: get(\"--border\", \"#ebebeb\"),\n    accent: get(\"--ns-accent\", \"#006bff\"),\n  };\n}\n\nfunction useTokens(): Tokens {\n  const [tokens, setTokens] = useState<Tokens>(() =>\n    typeof document === \"undefined\"\n      ? { fg: \"#171717\", bg: \"#ffffff\", muted: \"#4d4d4d\", border: \"#ebebeb\", accent: \"#006bff\" }\n      : readTokens()\n  );\n  useEffect(() => {\n    const sync = () => setTokens(readTokens());\n    sync();\n    const mo = new MutationObserver(sync);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    return () => mo.disconnect();\n  }, []);\n  return tokens;\n}\n\nexport function WaveformAsciiScrub({\n  value,\n  defaultValue = 0.3,\n  duration = 180,\n  onValueChange,\n  label = \"Scrub position\",\n  className = \"\",\n}: WaveformAsciiScrubProps) {\n  const uid = useId();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const tokens = useTokens();\n\n  const isControlled = value !== undefined;\n  const [inner, setInner] = useState(defaultValue);\n  const playhead = isControlled ? (value as number) : inner;\n\n  const [dragging, setDragging] = useState(false);\n  const [hoverFocus, setHoverFocus] = useState<number | null>(null);\n  const [focused, setFocused] = useState(false);\n\n  const reducedRef = useRef(false);\n  useEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  }, []);\n\n  function commit(next: number) {\n    const clamped = Math.max(0, Math.min(1, next));\n    if (!isControlled) setInner(clamped);\n    onValueChange?.(clamped);\n  }\n\n  function posFromClientX(clientX: number): number {\n    const rect = wrapRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return playhead;\n    return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n  }\n\n  function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>) {\n    e.currentTarget.setPointerCapture(e.pointerId);\n    setDragging(true);\n    commit(posFromClientX(e.clientX));\n  }\n  function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>) {\n    const pos = posFromClientX(e.clientX);\n    if (dragging) commit(pos);\n    else setHoverFocus(pos);\n  }\n  function handlePointerUp() {\n    setDragging(false);\n  }\n  function handlePointerLeave() {\n    if (!dragging) setHoverFocus(null);\n  }\n  function handleKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {\n    let next = playhead;\n    if (e.key === \"ArrowLeft\") next -= 0.01;\n    else if (e.key === \"ArrowRight\") next += 0.01;\n    else if (e.key === \"PageDown\") next -= 0.05;\n    else if (e.key === \"PageUp\") next += 0.05;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = 1;\n    else return;\n    e.preventDefault();\n    commit(next);\n  }\n\n  // priority: an active drag wins, then a hover preview, then keyboard focus\n  // parked at the committed playhead — otherwise no local focus at all.\n  const targetCenter = dragging ? playhead : hoverFocus !== null ? hoverFocus : focused ? playhead : null;\n\n  const radiusRef = useRef(0);\n  const centerRef = useRef(playhead);\n  const rafRef = useRef(0);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = VIEW_W * dpr;\n    canvas.height = VIEW_H * dpr;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const targetRadius = targetCenter === null ? 0 : FOCUS_RADIUS_COLS;\n    if (targetCenter !== null) centerRef.current = targetCenter;\n\n    const draw = () => {\n      ctx.clearRect(0, 0, VIEW_W, VIEW_H);\n\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(0, VIEW_H / 2 + 0.5);\n      ctx.lineTo(VIEW_W, VIEW_H / 2 + 0.5);\n      ctx.stroke();\n\n      const colW = VIEW_W / BASE_COLS;\n      ctx.font = `${Math.max(8, colW * 0.95)}px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillStyle = tokens.fg;\n\n      const rowH = (VIEW_H * 0.42) / MAX_ROWS;\n\n      let x = 0;\n      for (let i = 0; i < BASE_COLS; i++) {\n        const t0 = i / BASE_COLS;\n        const t1 = (i + 1) / BASE_COLS;\n        const center = (i + 0.5) / BASE_COLS;\n        const distCols = Math.abs(center - centerRef.current) * BASE_COLS;\n        const subdivided = radiusRef.current > 0.5 && distCols < radiusRef.current;\n        const parts = subdivided ? SUBDIVIDE : 1;\n        const partW = colW / parts;\n\n        for (let p = 0; p < parts; p++) {\n          const pt0 = t0 + ((t1 - t0) * p) / parts;\n          const pt1 = t0 + ((t1 - t0) * (p + 1)) / parts;\n          const amp = peakIn(pt0, pt1);\n          const rows = Math.max(1, Math.round(amp * MAX_ROWS));\n          const glyphIdx = Math.min(CHARSET.length - 1, Math.floor(amp * (CHARSET.length - 1)));\n          const glyph = CHARSET[glyphIdx];\n          const cx = x + partW / 2;\n          for (let r = 0; r < rows; r++) {\n            const yy = VIEW_H / 2 - (r + 0.5) * rowH;\n            ctx.fillText(glyph, cx, yy);\n            ctx.fillText(glyph, cx, VIEW_H - yy);\n          }\n          x += partW;\n        }\n      }\n\n      const px = playhead * VIEW_W;\n      ctx.strokeStyle = tokens.accent;\n      ctx.lineWidth = focused || dragging ? 2 : 1.5;\n      ctx.beginPath();\n      ctx.moveTo(px + 0.5, 6);\n      ctx.lineTo(px + 0.5, VIEW_H - 6);\n      ctx.stroke();\n\n      let animating = false;\n      if (reducedRef.current) {\n        radiusRef.current = targetRadius;\n      } else {\n        const dr = targetRadius - radiusRef.current;\n        if (Math.abs(dr) > EASE_EPS) {\n          radiusRef.current += dr * 0.25;\n          animating = true;\n        } else {\n          radiusRef.current = targetRadius;\n        }\n      }\n\n      if (animating) rafRef.current = requestAnimationFrame(draw);\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [tokens, playhead, targetCenter, focused, dragging]);\n\n  return (\n    <div className={`ns-was relative select-none ${className}`} style={{ width: VIEW_W, maxWidth: \"100%\" }}>\n      <style>{CSS}</style>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        style={{ width: VIEW_W, height: VIEW_H, maxWidth: \"100%\", display: \"block\" }}\n      />\n      <div\n        ref={wrapRef}\n        id={`${uid}-slider`}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(playhead * 100)}\n        aria-valuetext={formatTime(playhead, duration)}\n        aria-orientation=\"horizontal\"\n        className=\"ns-was-hit absolute inset-0 cursor-pointer touch-none outline-none\"\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerLeave={handlePointerLeave}\n        onKeyDown={handleKeyDown}\n        onFocus={() => setFocused(true)}\n        onBlur={() => {\n          setFocused(false);\n          setDragging(false);\n        }}\n      />\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-was-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/waveform-ascii-scrub.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "waveform",
      "ascii",
      "audio",
      "canvas",
      "scrub",
      "slider",
      "drag",
      "accessibility"
    ],
    "instruction": "<WaveformAsciiScrub value? defaultValue? duration? onValueChange? label? className?> draws a fixed-width canvas waveform from a locally synthesised (no audio API, no network) layered-sine amplitude function sampled peak-in-bucket across 64 base glyph columns, each rendered as a Geist Mono density character from ' .:-=+*#%@' stacked into a symmetric bar mirrored above and below the centerline. At rest every column is exactly that one coarse sample. Dragging the accent playhead (a role=slider div overlaying the canvas, pointer and keyboard driven), hovering anywhere over the strip, or holding it in keyboard focus subdivides the ~6 base columns nearest that position into 3 finer sub-columns apiece, each independently re-sampling its own narrower time span — genuinely finer detail, not an interpolated blur — while columns outside that radius stay at base resolution; the radius eases in and out over roughly 250ms rather than snapping, and collapses back to zero (fully coarse) the instant nothing is hovered, dragged or focused. Keyboard: ArrowLeft/Right step 1%, PageUp/PageDown step 5%, Home/End jump to the ends; aria-valuenow/aria-valuetext (an M:SS derived from the duration prop) update on every commit. Colors read live from --foreground/--border/--ns-accent via getComputedStyle, resynced on a documentElement class MutationObserver — no hardcoded hex. Under prefers-reduced-motion the resolution radius snaps instantly with no eased rAF loop. Zero dependencies."
  },
  "type": "registry:ui"
}