{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confirm-hold-ink",
  "title": "Confirm Hold Ink",
  "description": "Press-and-hold destructive action — monochrome ink pours up from the press point, release early and it recoils.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/confirm-hold-ink/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\n// Press-and-hold destructive action, redesigned as an ink fill: holding pours\n// monochrome ink up from the press point with a live meniscus edge and subtle\n// grain; pressure microshake builds near the top; releasing early recoils the\n// ink elastically; completion pops with one spring overshoot and swaps the\n// label. Canvas colors derive from computed CSS tokens (re-derived on theme\n// change), rAF sleeps when settled and pauses offscreen, reduced-motion gets a\n// plain clean fill. Direct-DOM on the hot path — React state only at confirm.\n\ntype Mode = \"idle\" | \"hold\" | \"recoil\" | \"pop\";\n\nexport function HoldToConfirm({\n  children,\n  confirmedLabel = \"Done\",\n  holdMs = 1200,\n  onConfirm,\n  className = \"\",\n}: {\n  /** button label shown before confirmation */\n  children: ReactNode;\n  /** label shown briefly after the hold completes */\n  confirmedLabel?: ReactNode;\n  /** ms the button must be held before it commits */\n  holdMs?: number;\n  /** called once the hold completes */\n  onConfirm?: () => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [confirmed, setConfirmed] = useState(false);\n  const onConfirmRef = useRef(onConfirm);\n  onConfirmRef.current = onConfirm;\n  const holdMsRef = useRef(holdMs);\n  holdMsRef.current = holdMs;\n\n  const stateRef = useRef({\n    mode: \"idle\" as Mode,\n    p: 0, // fill progress 0..1\n    v: 0, // recoil spring velocity (progress units / s)\n    holding: false,\n    done: false,\n    pressX: 0.5, // normalized press point\n    scale: 1,\n    sv: 0, // pop spring velocity\n    t: 0, // wave clock (ms)\n    raf: 0,\n    last: 0,\n    w: 0,\n    h: 0,\n    dpr: 1,\n    ink: \"#ededed\",\n    grainColor: \"#171717\",\n    reduced: false,\n    visible: true,\n    noise: null as HTMLCanvasElement | null,\n    pattern: null as CanvasPattern | null,\n  });\n\n  useEffect(() => {\n    const s = stateRef.current;\n    const btn = btnRef.current;\n    const canvas = canvasRef.current;\n    if (!btn || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const makeNoise = () => {\n      const n = document.createElement(\"canvas\");\n      n.width = 128;\n      n.height = 128;\n      const nctx = n.getContext(\"2d\");\n      if (!nctx) return;\n      nctx.fillStyle = s.grainColor;\n      for (let i = 0; i < 2400; i++) {\n        nctx.globalAlpha = Math.random() * 0.5;\n        nctx.fillRect(Math.floor(Math.random() * 128), Math.floor(Math.random() * 128), 1, 1);\n      }\n      s.noise = n;\n      s.pattern = null; // rebuilt lazily against the drawing ctx\n    };\n\n    // canvas colors from computed tokens: ink = button text color (foreground),\n    // grain = button background (surface) so texture reads inside the ink\n    const syncColors = () => {\n      const cs = getComputedStyle(btn);\n      s.ink = cs.color;\n      s.grainColor = cs.backgroundColor;\n      makeNoise();\n      draw();\n    };\n\n    const edgeY = (x: number, level: number) => {\n      let y = level;\n      // mound welling up at the press point, flattens as the fill matures\n      const px = s.pressX * s.w;\n      const spread = s.w * (0.1 + 0.9 * s.p);\n      const mound = s.h * 1.5 * s.p * (1 - s.p) ** 2;\n      y -= mound * Math.exp(-((x - px) ** 2) / (2 * spread * spread));\n      // rippling waterline + pressure swell near the top\n      const press = Math.min(1, Math.max(0, (s.p - 0.6) / 0.4));\n      const live = s.holding ? 1.6 : Math.min(3, Math.abs(s.v) * 2.5);\n      const amp = 1.2 + 2.6 * press + live;\n      y += amp * 0.6 * Math.sin(x * 0.085 + s.t * 0.012);\n      y += amp * 0.4 * Math.sin(x * 0.041 - s.t * 0.0085);\n      return y;\n    };\n\n    const draw = () => {\n      const { w, h, dpr } = s;\n      if (!w || !h) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      if (s.p <= 0.001) return;\n      ctx.fillStyle = s.ink;\n      if (s.p >= 0.999 || s.reduced) {\n        // reduced motion: simple clean fill, no meniscus, no grain\n        ctx.fillRect(0, h * (1 - s.p), w, h * s.p + 1);\n        if (s.reduced) return;\n      } else {\n        const level = h * (1 - s.p);\n        ctx.beginPath();\n        ctx.moveTo(0, h + 2);\n        for (let x = 0; x <= w; x += 3) ctx.lineTo(x, edgeY(x, level));\n        ctx.lineTo(w, edgeY(w, level));\n        ctx.lineTo(w, h + 2);\n        ctx.closePath();\n        ctx.fill();\n      }\n      // grain, clipped to the ink via source-atop\n      if (s.noise) {\n        if (!s.pattern) s.pattern = ctx.createPattern(s.noise, \"repeat\");\n        if (s.pattern) {\n          ctx.globalCompositeOperation = \"source-atop\";\n          ctx.globalAlpha = 0.12;\n          ctx.fillStyle = s.pattern;\n          ctx.fillRect(0, 0, w, h);\n          ctx.globalAlpha = 1;\n          ctx.globalCompositeOperation = \"source-over\";\n        }\n      }\n    };\n\n    const applyTransform = () => {\n      if (s.mode === \"hold\") {\n        const press = Math.min(1, Math.max(0, (s.p - 0.6) / 0.4));\n        const j = s.reduced ? 0 : press * press * 1.6;\n        const jx = (Math.random() - 0.5) * 2 * j;\n        const jy = (Math.random() - 0.5) * 2 * j;\n        btn.style.transform = `translate(${jx.toFixed(2)}px, ${jy.toFixed(2)}px) scale(0.985)`;\n      } else if (s.mode === \"recoil\") {\n        const squash = 1 - 0.015 * Math.min(1, s.p * 3);\n        btn.style.transform = `scale(${squash.toFixed(4)})`;\n      } else if (s.mode === \"pop\") {\n        btn.style.transform = `scale(${s.scale.toFixed(4)})`;\n      } else {\n        btn.style.transform = \"\";\n      }\n    };\n\n    const settle = () => {\n      s.mode = \"idle\";\n      s.raf = 0;\n      applyTransform();\n      draw();\n    };\n\n    const confirm = () => {\n      s.p = 1;\n      s.done = true;\n      s.holding = false;\n      if (s.reduced) {\n        s.mode = \"idle\";\n      } else {\n        s.mode = \"pop\";\n        s.scale = 0.97;\n        s.sv = 0.6;\n      }\n      setConfirmed(true);\n      onConfirmRef.current?.();\n    };\n\n    const tick = (now: number) => {\n      const rawMs = now - s.last; // wall time, keeps hold duration honest across frame stalls\n      const dtMs = Math.min(64, rawMs); // clamped for springs/waves so physics can't blow up\n      s.last = now;\n      const dt = dtMs / 1000;\n      s.t += dtMs;\n\n      if (s.mode === \"hold\") {\n        s.p += rawMs / holdMsRef.current;\n        if (s.p >= 1) confirm();\n      } else if (s.mode === \"recoil\") {\n        if (s.reduced) {\n          s.p -= (dtMs / holdMsRef.current) * 2.5;\n          if (s.p <= 0) {\n            s.p = 0;\n            draw();\n            return settle();\n          }\n        } else {\n          // elastic recoil: damped spring toward 0, bounce at the floor\n          s.v += (-220 * s.p - 16 * s.v) * dt;\n          s.p += s.v * dt;\n          if (s.p <= 0) {\n            s.p = 0;\n            s.v = -s.v * 0.3;\n          }\n          if (s.p < 0.008 && Math.abs(s.v) < 0.2) {\n            s.p = 0;\n            draw();\n            return settle();\n          }\n        }\n      } else if (s.mode === \"pop\") {\n        s.sv += ((1 - s.scale) * 300 - 12 * s.sv) * dt;\n        s.scale += s.sv * dt;\n        if (Math.abs(s.scale - 1) < 0.001 && Math.abs(s.sv) < 0.01) {\n          s.scale = 1;\n          draw();\n          return settle();\n        }\n      } else {\n        return settle();\n      }\n\n      draw();\n      applyTransform();\n      if (s.visible) {\n        s.raf = requestAnimationFrame(tick);\n      } else {\n        s.raf = 0;\n      }\n    };\n\n    const wake = () => {\n      if (s.raf || s.mode === \"idle\" || !s.visible) return;\n      s.last = performance.now();\n      s.raf = requestAnimationFrame(tick);\n    };\n\n    const startHold = (pressX: number) => {\n      if (s.done || s.mode === \"pop\") return;\n      s.holding = true;\n      s.mode = \"hold\";\n      s.pressX = pressX;\n      s.v = 0;\n      wake();\n    };\n\n    const release = () => {\n      if (!s.holding || s.done) return;\n      s.holding = false;\n      if (s.p > 0) {\n        s.mode = \"recoil\";\n        s.v = -1.2; // initial elastic kick downward\n        wake();\n      } else {\n        s.mode = \"idle\";\n        applyTransform();\n      }\n    };\n\n    // wire input through the element so cleanup is guaranteed\n    const onPointerDown = (e: PointerEvent) => {\n      btn.setPointerCapture(e.pointerId);\n      const r = btn.getBoundingClientRect();\n      startHold(r.width ? (e.clientX - r.left) / r.width : 0.5);\n    };\n    const onPointerEnd = () => release();\n    const onKeyDown = (e: KeyboardEvent) => {\n      if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n        e.preventDefault();\n        startHold(0.5);\n      }\n    };\n    const onKeyUp = (e: KeyboardEvent) => {\n      if (e.key === \" \" || e.key === \"Enter\") release();\n    };\n    btn.addEventListener(\"pointerdown\", onPointerDown);\n    btn.addEventListener(\"pointerup\", onPointerEnd);\n    btn.addEventListener(\"pointercancel\", onPointerEnd);\n    btn.addEventListener(\"lostpointercapture\", onPointerEnd);\n    btn.addEventListener(\"keydown\", onKeyDown);\n    btn.addEventListener(\"keyup\", onKeyUp);\n    btn.addEventListener(\"blur\", onPointerEnd);\n\n    const ro = new ResizeObserver(() => {\n      const r = btn.getBoundingClientRect();\n      s.w = r.width;\n      s.h = r.height;\n      s.dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(s.w * s.dpr));\n      canvas.height = Math.max(1, Math.round(s.h * s.dpr));\n      draw();\n    });\n    ro.observe(btn);\n\n    const io = new IntersectionObserver(([entry]) => {\n      s.visible = entry.isIntersecting;\n      if (s.visible) wake();\n      else if (s.raf) {\n        cancelAnimationFrame(s.raf);\n        s.raf = 0;\n      }\n    });\n    io.observe(btn);\n\n    const mo = new MutationObserver(syncColors);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onMq = () => {\n      s.reduced = mq.matches;\n      draw();\n    };\n    onMq();\n    mq.addEventListener(\"change\", onMq);\n\n    syncColors();\n\n    return () => {\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      btn.removeEventListener(\"pointerdown\", onPointerDown);\n      btn.removeEventListener(\"pointerup\", onPointerEnd);\n      btn.removeEventListener(\"pointercancel\", onPointerEnd);\n      btn.removeEventListener(\"lostpointercapture\", onPointerEnd);\n      btn.removeEventListener(\"keydown\", onKeyDown);\n      btn.removeEventListener(\"keyup\", onKeyUp);\n      btn.removeEventListener(\"blur\", onPointerEnd);\n    };\n  }, []);\n\n  return (\n    <button\n      ref={btnRef}\n      type=\"button\"\n      className={[\n        \"relative isolate inline-flex select-none touch-none items-center justify-center overflow-hidden\",\n        \"rounded-sm border border-border bg-surface px-5 py-2.5 text-sm font-medium text-foreground\",\n        \"hover:border-ns-muted hover:bg-border/60\",\n        \"transition-[border-color,background-color] duration-150\",\n        \"focus:border-ns-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\",\n        className,\n      ].join(\" \")}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n      {/* difference blend inverts the label wherever the ink covers it */}\n      <span className=\"relative z-10\" style={{ mixBlendMode: \"difference\", color: \"#fff\" }}>\n        {confirmed ? confirmedLabel : children}\n      </span>\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/confirm-hold-ink.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "button",
      "destructive",
      "micro-interaction",
      "confirmation",
      "canvas"
    ],
    "instruction": "A destructive-action button requiring press-and-hold, rendered as a canvas ink fill: holding pours monochrome ink (foreground token) up from the press point with a live rippling meniscus edge, subtle grain, and a pressure microshake as it nears the top; the label inverts over the ink via a difference blend. Releasing early (including pointercancel or blur) recoils the ink with an elastic damped spring; when the fill completes, the button pops with one spring overshoot and the label swaps. Works with pointer and keyboard (hold Space/Enter). Canvas colors derive from computed CSS tokens and re-derive on theme change; the rAF loop sleeps when settled and pauses offscreen; prefers-reduced-motion still holds-to-confirm but renders a plain clean fill with no waves, grain, shake, or pop."
  },
  "type": "registry:ui"
}