{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "web-haptics-demo",
  "type": "registry:ui",
  "title": "Web Haptics",
  "description": "A synthesized UI sound pack, no audio files. Warm oscillator cues laddered by weight, with a playable grid and a live toggle, slider, and confirm.",
  "author": "Samuel (https://samhc.us)",
  "files": [
    {
      "path": "registry/samuel/web-haptics-demo.tsx",
      "type": "registry:ui",
      "target": "components/ui/web-haptics-demo.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { playSound, SOUND_PACK } from \"./ui-sound\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\n\n/** The whole pack, playable — tap any nub to hear it. Weight-laddered. */\nexport function WebHapticsGrid() {\n  return (\n    <div className=\"grid grid-cols-2 gap-2 sm:grid-cols-3\">\n      {SOUND_PACK.map((s) => (\n        <button\n          key={s.name}\n          type=\"button\"\n          onClick={() => playSound(s.name)}\n          aria-label={`Play ${s.label} haptic`}\n          className={cn(\n            \"flex flex-col items-start gap-0.5 rounded-xl border border-border bg-card px-3.5 py-3 text-left outline-none transition-[transform,background-color,border-color] duration-150 active:scale-[0.97]\",\n            EASE,\n            \"[@media(hover:hover)]:hover:border-foreground/25 [@media(hover:hover)]:hover:bg-muted/50\",\n          )}\n        >\n          <span className=\"text-[14px] font-medium leading-none text-foreground\">{s.label}</span>\n          <span className=\"text-[11px] leading-none text-muted-foreground\">{s.weight}</span>\n        </button>\n      ))}\n    </div>\n  );\n}\n\n/** Live UI to feel the pack in context: a toggle, a slider, a confirm, a notify. */\nexport function WebHapticsPlayground() {\n  const [on, setOn] = useState(false);\n  const [val, setVal] = useState(0.5);\n  const [pinged, setPinged] = useState(false);\n  const pingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const lastTick = useRef(0.5);\n\n  // The detent only ticks once the value has actually travelled: holding the\n  // pointer past either end pins the value, so the cue stays silent there instead\n  // of looping every pointermove.\n  const dragSlider = (e: React.PointerEvent) => {\n    const el = e.currentTarget as HTMLDivElement;\n    el.setPointerCapture(e.pointerId);\n    const r = el.getBoundingClientRect();\n    const raw = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));\n    setVal(raw);\n    if (Math.abs(raw - lastTick.current) >= 0.045) {\n      lastTick.current = raw;\n      playSound(\"haptic\");\n    }\n  };\n\n  const ring = () => {\n    playSound(\"ding\");\n    setPinged(true);\n    if (pingTimer.current) clearTimeout(pingTimer.current);\n    pingTimer.current = setTimeout(() => setPinged(false), 2600);\n  };\n\n  return (\n    <div className=\"flex flex-col gap-5 rounded-2xl border border-border bg-card p-5\">\n      {/* Toggle — on/off cue */}\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-[14px] text-foreground\">Toggle</span>\n        <button\n          type=\"button\"\n          onClick={() => { const n = !on; playSound(n ? \"on\" : \"off\"); setOn(n); }}\n          aria-pressed={on}\n          aria-label=\"Toggle\"\n          className={cn(\"relative h-6 w-11 rounded-[8px] outline-none transition-colors duration-200\", EASE, on ? \"bg-foreground\" : \"bg-muted\")}\n        >\n          <span className={cn(\"absolute top-0.5 h-5 w-5 rounded-[6px] bg-background shadow-sm transition-transform duration-200\", EASE, on ? \"translate-x-[22px]\" : \"translate-x-0.5\")} />\n        </button>\n      </div>\n\n      {/* Slider — haptic detent per step */}\n      <div className=\"flex items-center gap-4\">\n        <span className=\"shrink-0 text-[14px] text-foreground\">Slider</span>\n        <div\n          onPointerDown={dragSlider}\n          onPointerMove={(e) => e.buttons === 1 && dragSlider(e)}\n          aria-label=\"Slider\"\n          className=\"relative h-4 flex-1 cursor-pointer touch-none rounded-md bg-muted\"\n        >\n          <div className=\"absolute inset-y-0 left-0 rounded-md bg-foreground\" style={{ width: `${val * 100}%` }} />\n          <span className=\"pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-[5px] border-2 border-background bg-foreground shadow-[0_1px_4px_rgba(0,0,0,0.35)]\" style={{ left: `${val * 100}%` }} />\n        </div>\n      </div>\n\n      {/* Actions: success + the notification bell */}\n      <div className=\"flex gap-2\">\n        <button\n          type=\"button\"\n          onClick={() => playSound(\"success\")}\n          className={cn(\"flex-1 rounded-xl bg-foreground py-2.5 text-[14px] font-medium text-background outline-none transition-transform duration-150 active:scale-[0.97]\", EASE)}\n        >\n          Confirm\n        </button>\n        <button\n          type=\"button\"\n          onClick={ring}\n          aria-live=\"polite\"\n          className={cn(\"flex-1 rounded-xl border border-border py-2.5 text-[14px] font-medium text-foreground outline-none transition-[transform,background-color] duration-150 active:scale-[0.97] [@media(hover:hover)]:hover:bg-muted/50\", EASE)}\n        >\n          {pinged ? \"Bell rung\" : \"Notify me\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/samuel/ui-sound.ts",
      "type": "registry:lib",
      "target": "lib/ui-sound.ts",
      "content": "\"use client\";\n\n/* ── Web Haptics Sound Pack ───────────────────────────────────────────────────\n   A tiny Web-Audio synth: one cohesive, premium pack of UI \"haptic nubs\" wired\n   end-to-end site-wide. No audio-file assets — each cue is an oscillator through\n   a lowpass (warm, tactile, never a thin digital beep) + a fast-attack /\n   exp-decay envelope, so there's nothing to preload and nothing to 404.\n\n   The pack is a WEIGHT LADDER, lightest → firmest, unified in tone, distinct in\n   feel (see SOUNDS below):\n     hover   — feather nudge, before a click\n     haptic  — slider detent tick\n     tick    — neutral discrete tap\n     select  — a rounded \"pock\", pick / choose\n     on/off  — toggle up / down\n     expand/collapse — panel open / close\n     success — bright confirming up-arpeggio\n     error   — soft low \"no\", not harsh\n     ding    — a warm notification bell (inharmonic partials, long decay)\n     reset   — a longer downward sweep, \"wiped\"\n   Every cue is SUBTLE and short (<300ms) and weight-matched to its action.\n\n   Guarantees, per the sounds-on-the-web rules:\n   - Never the sole feedback — the island already gives every action a visual\n     change; sound only ever accompanies it.\n   - Silenced under prefers-reduced-motion (proxy for sound sensitivity).\n   - Scoped: only the dynamic toolbar (island) and the Web Haptics post play\n     these cues. Every other surface stays silent.\n   - Never wired to high-frequency events (strokes, slider drags, hovers) — those\n     stay silent. Only discrete state changes make a sound. */\n\nexport type SoundName =\n  | \"hover\"\n  | \"haptic\"\n  | \"tick\"\n  | \"select\"\n  | \"on\"\n  | \"off\"\n  | \"expand\"\n  | \"collapse\"\n  | \"success\"\n  | \"error\"\n  | \"ding\"\n  | \"reset\";\n\n/** The pack, in weight order — drives the playable grid in the Web Haptics post. */\nexport const SOUND_PACK: { name: SoundName; label: string; weight: string }[] = [\n  { name: \"hover\", label: \"Hover\", weight: \"Feather\" },\n  { name: \"haptic\", label: \"Detent\", weight: \"Feather\" },\n  { name: \"tick\", label: \"Tick\", weight: \"Light\" },\n  { name: \"select\", label: \"Select\", weight: \"Light\" },\n  { name: \"on\", label: \"Toggle on\", weight: \"Medium\" },\n  { name: \"off\", label: \"Toggle off\", weight: \"Medium\" },\n  { name: \"expand\", label: \"Expand\", weight: \"Medium\" },\n  { name: \"collapse\", label: \"Collapse\", weight: \"Medium\" },\n  { name: \"success\", label: \"Success\", weight: \"Firm\" },\n  { name: \"error\", label: \"Error\", weight: \"Firm\" },\n  { name: \"ding\", label: \"Notify\", weight: \"Firm\" },\n  { name: \"reset\", label: \"Reset\", weight: \"Firm\" },\n];\n\nlet ctx: AudioContext | null = null;\nlet master: GainNode | null = null;\n\nconst MASTER_GAIN = 0.26; // subtle by default — chrome, not a game\n\nfunction reducedMotion(): boolean {\n  try {\n    return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  } catch {\n    return false;\n  }\n}\n\n// AudioContext must be created/resumed inside a user gesture; every caller here\n// is a click handler, so the first cue lazily spins it up.\nfunction ensure(): AudioContext | null {\n  if (typeof window === \"undefined\") return null;\n  if (!ctx) {\n    const AC: typeof AudioContext | undefined =\n      window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n    if (!AC) return null;\n    ctx = new AC();\n    master = ctx.createGain();\n    master.gain.value = MASTER_GAIN;\n    master.connect(ctx.destination);\n  }\n  if (ctx.state === \"suspended\") void ctx.resume();\n  return ctx;\n}\n\ntype Blip = {\n  freq: number;\n  slideTo?: number; // exponential glide to this freq over the blip's life\n  dur: number; // seconds\n  type?: OscillatorType;\n  gain?: number; // 0..1 relative to master\n  delay?: number; // seconds after \"now\" to start\n  cutoff?: number; // lowpass cutoff (Hz) — lower = warmer, rounder, less digital\n};\n\nfunction blip(b: Blip): void {\n  const c = ensure();\n  if (!c || !master) return;\n  const now = c.currentTime + (b.delay ?? 0);\n  const osc = c.createOscillator();\n  const g = c.createGain();\n  // A lowpass shaves the harsh oscillator top end so every cue reads warm and\n  // tactile (a soft \"tock\") rather than a thin digital beep — the premium feel.\n  const lp = c.createBiquadFilter();\n  lp.type = \"lowpass\";\n  lp.frequency.value = b.cutoff ?? 2400;\n  lp.Q.value = 0.7;\n  osc.type = b.type ?? \"sine\";\n  osc.frequency.setValueAtTime(b.freq, now);\n  if (b.slideTo) osc.frequency.exponentialRampToValueAtTime(b.slideTo, now + b.dur);\n  const peak = b.gain ?? 0.7;\n  // exp ramps can't touch 0 — start/end just above silence to avoid clicks.\n  g.gain.setValueAtTime(0.0001, now);\n  g.gain.exponentialRampToValueAtTime(peak, now + 0.005);\n  g.gain.exponentialRampToValueAtTime(0.0001, now + b.dur);\n  osc.connect(lp);\n  lp.connect(g);\n  g.connect(master);\n  osc.start(now);\n  osc.stop(now + b.dur + 0.03);\n}\n\n// A tiny band-passed noise burst — the transient \"click\" of a real tactile tap.\n// Layered under the tonal cues (taps, resets) it adds the physical edge a pure\n// oscillator can't, so each cue feels like a surface, not a tone.\nfunction noise(o: { dur: number; gain: number; freq: number; delay?: number }): void {\n  const c = ensure();\n  if (!c || !master) return;\n  const now = c.currentTime + (o.delay ?? 0);\n  const len = Math.max(1, Math.ceil(c.sampleRate * o.dur));\n  const buf = c.createBuffer(1, len, c.sampleRate);\n  const d = buf.getChannelData(0);\n  for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;\n  const src = c.createBufferSource();\n  src.buffer = buf;\n  const bp = c.createBiquadFilter();\n  bp.type = \"bandpass\";\n  bp.frequency.value = o.freq;\n  bp.Q.value = 0.9;\n  const g = c.createGain();\n  g.gain.setValueAtTime(o.gain, now);\n  g.gain.exponentialRampToValueAtTime(0.0001, now + o.dur);\n  src.connect(bp);\n  bp.connect(g);\n  g.connect(master);\n  src.start(now);\n  src.stop(now + o.dur + 0.02);\n}\n\n// Each cue has its OWN identity — a distinct timbre, register and gesture — while\n// sharing the warm filtered tone. Taps carry a noise transient (a real surface),\n// sweeps glide, the bell rings inharmonic partials. Kept short + quiet so daily\n// use never nags.\nconst SOUNDS: Record<SoundName, () => void> = {\n  // feather nudge before a click — a whisper-quiet high tick, nothing else\n  hover: () => {\n    blip({ freq: 900, dur: 0.012, type: \"sine\", gain: 0.16, cutoff: 2400 });\n  },\n  // slider detent — a low wooden \"tk\": a soft body under a tiny noise click\n  haptic: () => {\n    noise({ dur: 0.009, gain: 0.08, freq: 1500 });\n    blip({ freq: 150, dur: 0.03, type: \"sine\", gain: 0.42, cutoff: 460 });\n  },\n  // neutral discrete tap — a crisper mid click, brighter than the detent\n  tick: () => {\n    noise({ dur: 0.007, gain: 0.06, freq: 3200 });\n    blip({ freq: 540, dur: 0.028, type: \"triangle\", gain: 0.34, cutoff: 2000 });\n  },\n  // pick / choose — a rounded pluck that lifts in pitch, with a faint overtone\n  select: () => {\n    blip({ freq: 380, slideTo: 560, dur: 0.06, type: \"sine\", gain: 0.5, cutoff: 1600 });\n    blip({ freq: 900, dur: 0.045, type: \"sine\", gain: 0.12, cutoff: 2600, delay: 0.004 });\n  },\n  // toggle on — two rising notes, a bright major-ish step\n  on: () => {\n    blip({ freq: 494, dur: 0.06, type: \"triangle\", gain: 0.44, cutoff: 2000 });\n    blip({ freq: 740, dur: 0.09, type: \"triangle\", gain: 0.4, cutoff: 2300, delay: 0.052 });\n  },\n  // toggle off — the same shape, falling\n  off: () => {\n    blip({ freq: 660, dur: 0.06, type: \"triangle\", gain: 0.44, cutoff: 1900 });\n    blip({ freq: 415, dur: 0.1, type: \"triangle\", gain: 0.4, cutoff: 1500, delay: 0.052 });\n  },\n  // panel opens — a warm sweep up with a soft high sparkle on top\n  expand: () => {\n    blip({ freq: 320, slideTo: 620, dur: 0.13, type: \"sine\", gain: 0.55, cutoff: 1900 });\n    blip({ freq: 1050, dur: 0.05, type: \"sine\", gain: 0.14, cutoff: 3000, delay: 0.075 });\n  },\n  // panel closes — a single warm sweep down (no sparkle, so it's clearly distinct)\n  collapse: () => {\n    blip({ freq: 640, slideTo: 300, dur: 0.14, type: \"sine\", gain: 0.5, cutoff: 1600 });\n  },\n  // success — a bright three-note major arpeggio (D F# A)\n  success: () => {\n    blip({ freq: 587, dur: 0.08, type: \"triangle\", gain: 0.4, cutoff: 2600 });\n    blip({ freq: 740, dur: 0.08, type: \"triangle\", gain: 0.4, cutoff: 2800, delay: 0.07 });\n    blip({ freq: 880, dur: 0.2, type: \"sine\", gain: 0.46, cutoff: 3200, delay: 0.14 });\n  },\n  // error — a low, dull, slightly rough double thud (sawtooth, heavily filtered)\n  error: () => {\n    blip({ freq: 220, slideTo: 190, dur: 0.11, type: \"sawtooth\", gain: 0.34, cutoff: 460 });\n    blip({ freq: 175, slideTo: 150, dur: 0.14, type: \"sawtooth\", gain: 0.32, cutoff: 420, delay: 0.1 });\n  },\n  // notification — a warm bell: a fundamental, an inharmonic partial (the metallic\n  // shimmer that makes it a bell not a beep), and a fifth, all with a long decay\n  ding: () => {\n    blip({ freq: 784, dur: 0.5, type: \"sine\", gain: 0.48, cutoff: 4200 });\n    blip({ freq: 784 * 2.76, dur: 0.38, type: \"sine\", gain: 0.1, cutoff: 5200 });\n    blip({ freq: 1175, dur: 0.55, type: \"sine\", gain: 0.26, cutoff: 4600, delay: 0.02 });\n  },\n  // reset — the longest gesture: a downward sweep with a soft noise whoosh under it\n  reset: () => {\n    blip({ freq: 700, slideTo: 150, dur: 0.28, type: \"sine\", gain: 0.5, cutoff: 1500 });\n    noise({ dur: 0.26, gain: 0.035, freq: 700 });\n  },\n};\n\n// The high-frequency cues (hover, slider steps) are throttled so a sweep across\n// the island reads as a soft patter, never a stacked drone.\nconst lastAt: Partial<Record<SoundName, number>> = {};\n\n/** Play a named chrome cue. No-ops under reduced-motion or SSR. */\nexport function playSound(name: SoundName): void {\n  if (typeof window === \"undefined\") return;\n  if (reducedMotion()) return;\n  if (name === \"hover\" || name === \"haptic\") {\n    const now = performance.now();\n    const min = name === \"hover\" ? 45 : 24;\n    if (now - (lastAt[name] ?? 0) < min) return;\n    lastAt[name] = now;\n  }\n  SOUNDS[name]?.();\n}\n"
    }
  ]
}
