{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "throw",
  "type": "registry:ui",
  "title": "Throw",
  "description": "Slide-to-commit track and swipe-action rows. Throw: drag the knob (or arrow to 100% and press Enter) past the threshold and it snaps home, floods the track and turns into a check; the label shimmers and fades as the knob passes, early releases spring back. ThrowRow: swipe a list row to reveal leading/trailing actions with rubber-banding and scale-in icons; a full swipe past 60% fires the primary and the row collapses on a height spring. Slider semantics, arrow-key reveal, ref handles; tweens only under reduced motion.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/throw/throw.tsx",
      "type": "registry:ui",
      "target": "components/crucible/throw.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useMotionValueEvent,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/* -------------------------------------------------------------------------- */\n/*                                   shared                                   */\n/* -------------------------------------------------------------------------- */\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Picks a legible ink (near-black or white) for text drawn on `hex`. */\nfunction inkOn(color: string): string {\n  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());\n  if (!m) return \"#0a0a0a\";\n  let h = m[1];\n  if (h.length === 3) h = h.replace(/./g, (c) => c + c);\n  const lin = (i: number) => {\n    const c = parseInt(h.slice(i, i + 2), 16) / 255;\n    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n  };\n  const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);\n  // Contrast against black vs white — whichever wins.\n  return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? \"#0a0a0a\" : \"#ffffff\";\n}\n\nconst SPRING = { type: \"spring\", stiffness: 520, damping: 38, mass: 0.9 } as const;\nconst SOFT = { type: \"spring\", stiffness: 300, damping: 30 } as const;\nconst QUICK = { duration: 0.14, ease: \"easeOut\" } as const;\n\n/* -------------------------------------------------------------------------- */\n/*                                    Throw                                   */\n/* -------------------------------------------------------------------------- */\n\n/** Imperative handle for {@link Throw}. */\nexport interface ThrowHandle {\n  /** Return the knob to the start and clear the committed state. */\n  reset(): void;\n  /** Move keyboard focus to the knob. */\n  focus(): void;\n}\n\nexport interface ThrowProps {\n  /** Instruction text inside the track; also the slider's accessible name. @default \"Slide to confirm\" */\n  label?: string;\n  /** Text shown in the filled track once committed. @default \"Done\" */\n  committedLabel?: string;\n  /** Fires once when the knob is thrown past the threshold (or Enter is pressed at the end). */\n  onCommit?: () => void;\n  /** Fraction of the travel (0–1) the knob must pass on release to commit. @default 0.85 */\n  threshold?: number;\n  /** Fill colour behind the knob and of the committed track. Any CSS colour; hex gets auto-contrast ink. @default \"#f5f5f5\" */\n  accentColor?: string;\n  /** Disable dragging and keys. @default false */\n  disabled?: boolean;\n  /** Idle hint — the knob nudges forward every few seconds until first touched. @default true */\n  hint?: boolean;\n  /** Freeze the shimmer and idle hint. @default false */\n  paused?: boolean;\n  className?: string;\n  /** Imperative handle — `{ reset(), focus() }`. */\n  ref?: React.Ref<ThrowHandle>;\n}\n\nconst KNOB = 48;\nconst PAD = 4;\n\n/**\n * Throw — a slide-to-commit track. Drag the knob (or use the arrow keys) to the\n * end: past `threshold` on release it snaps home and commits, the track floods\n * with the accent, the knob turns into a check. Released early it springs back.\n * The instruction label shimmers and fades as the knob passes over it.\n *\n * Accessibility: the knob is a `role=\"slider\"` (0–100, with valuetext);\n * Arrow/PageUp/PageDown/Home/End move it and Enter or Space commits once it sits\n * at 100%. Reduced motion drops the shimmer, springs and idle hint.\n */\nexport function Throw({\n  label = \"Slide to confirm\",\n  committedLabel = \"Done\",\n  onCommit,\n  threshold = 0.85,\n  accentColor = \"#f5f5f5\",\n  disabled = false,\n  hint = true,\n  paused = false,\n  className,\n  ref,\n}: ThrowProps) {\n  const reduced = useReducedMotion();\n  const still = reduced || paused;\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const knobRef = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(trackRef, { amount: 0.4 });\n  const descId = React.useId();\n\n  const x = useMotionValue(0);\n  const max = useMotionValue(0);\n  const progress = useTransform([x, max], ([a, b]: number[]) => (b > 0 ? clamp(a / b, 0, 1) : 0));\n  const fillW = useTransform(x, (v) => v + KNOB);\n  const labelOpacity = useTransform(progress, [0, 0.55], [1, 0]);\n  const labelShift = useTransform(progress, [0, 1], [0, 18]);\n\n  const [committed, setCommitted] = React.useState(false);\n  const [pct, setPct] = React.useState(0);\n  const [dragging, setDragging] = React.useState(false);\n  const [armed, setArmed] = React.useState(false);\n  const [flash, setFlash] = React.useState(0);\n\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const touched = React.useRef(false);\n  const drag = React.useRef<{ id: number; sx: number; start: number } | null>(null);\n  const committedRef = React.useRef(false);\n  const onCommitRef = React.useRef(onCommit);\n  React.useEffect(() => {\n    onCommitRef.current = onCommit;\n  });\n\n  const run = React.useCallback(\n    (to: number | number[], t: object) => {\n      anim.current?.stop();\n      anim.current = animate(x, to as number, t);\n    },\n    [x]\n  );\n\n  // Track width → travel. A committed knob stays pinned to the end on resize.\n  React.useLayoutEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    const measure = () => {\n      const m = Math.max(0, el.clientWidth - KNOB - PAD * 2);\n      max.set(m);\n      if (committedRef.current) x.set(m);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [max, x]);\n\n  useMotionValueEvent(progress, \"change\", (p) => {\n    if (drag.current) setArmed(p >= threshold);\n  });\n\n  const commit = React.useCallback(() => {\n    if (committedRef.current) return;\n    committedRef.current = true;\n    touched.current = true;\n    setCommitted(true);\n    setArmed(false);\n    setPct(100);\n    setFlash((n) => n + 1);\n    run(max.get(), still ? QUICK : SOFT);\n    onCommitRef.current?.();\n  }, [max, run, still]);\n\n  const goTo = React.useCallback(\n    (p: number) => {\n      const next = clamp(p, 0, 1);\n      setPct(Math.round(next * 100));\n      run(next * max.get(), still ? QUICK : SPRING);\n    },\n    [max, run, still]\n  );\n\n  const reset = React.useCallback(() => {\n    committedRef.current = false;\n    setCommitted(false);\n    setArmed(false);\n    setPct(0);\n    run(0, still ? QUICK : SOFT);\n  }, [run, still]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({ reset, focus: () => knobRef.current?.focus() }),\n    [reset]\n  );\n\n  // Idle hint: a seeded-cadence nudge that teaches the gesture. Also serves as\n  // the designed intro (first nudge lands shortly after the track enters view).\n  React.useEffect(() => {\n    if (!hint || still || disabled || committed || !inView) return;\n    let timer = 0;\n    const tease = () => {\n      const el = knobRef.current;\n      if (\n        touched.current ||\n        committedRef.current ||\n        drag.current ||\n        (el && document.activeElement === el)\n      )\n        return;\n      const m = max.get();\n      run([0, m * 0.24, 0], { duration: 1.15, times: [0, 0.38, 1], ease: [\"easeOut\", [0.5, 0, 0.2, 1]] });\n      timer = window.setTimeout(tease, 4200);\n    };\n    timer = window.setTimeout(tease, 700);\n    return () => window.clearTimeout(timer);\n  }, [hint, still, disabled, committed, inView, max, run]);\n\n  React.useEffect(() => () => anim.current?.stop(), []);\n\n  /* ------------------------------- pointer -------------------------------- */\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || committedRef.current || e.button !== 0) return;\n    e.preventDefault();\n    touched.current = true;\n    anim.current?.stop();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    e.currentTarget.focus({ preventScroll: true });\n    drag.current = { id: e.pointerId, sx: e.clientX, start: x.get() };\n    setDragging(true);\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    const m = max.get();\n    const nx = clamp(d.start + e.clientX - d.sx, 0, m);\n    x.set(nx);\n    setPct(m > 0 ? Math.round((nx / m) * 100) : 0);\n  };\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    setDragging(false);\n    setArmed(false);\n    if (progress.get() >= threshold) commit();\n    else goTo(0);\n  };\n\n  /* ------------------------------ keyboard -------------------------------- */\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    if (committedRef.current) return;\n    const p = pct / 100;\n    const big = e.shiftKey ? 0.25 : 0.1;\n    let next: number | null = null;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = p + big;\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = p - big;\n        break;\n      case \"PageUp\":\n        next = p + 0.25;\n        break;\n      case \"PageDown\":\n        next = p - 0.25;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = 1;\n        break;\n      case \"Enter\":\n      case \" \":\n        e.preventDefault();\n        if (pct >= 100) commit();\n        return;\n      default:\n        return;\n    }\n    e.preventDefault();\n    touched.current = true;\n    goTo(next);\n  };\n\n  // A half-thrown knob left by the keyboard slides home when focus leaves.\n  const onBlur = () => {\n    if (!committedRef.current && !drag.current && pct > 0) goTo(0);\n  };\n\n  const valueText = committed\n    ? committedLabel\n    : pct >= 100\n      ? \"At the end — press Enter to commit\"\n      : `${pct}%`;\n\n  const ink = inkOn(accentColor);\n  const tint = (a: number) => `color-mix(in srgb, ${accentColor} ${a}%, transparent)`;\n\n  return (\n    <div\n      ref={trackRef}\n      data-crucible=\"throw\"\n      data-committed={committed || undefined}\n      className={cn(\n        \"relative isolate h-14 w-full max-w-xs touch-pan-y select-none rounded-full\",\n        \"border border-white/[0.07] bg-neutral-900\",\n        \"shadow-[inset_0_2px_6px_rgba(0,0,0,0.55),inset_0_-1px_0_rgba(255,255,255,0.04),0_1px_0_rgba(255,255,255,0.03)]\",\n        disabled && \"opacity-50\",\n        className\n      )}\n    >\n      {/* Instruction label — shimmers, then fades and drifts as the knob passes. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 flex items-center justify-center pl-12 pr-4 text-sm font-medium tracking-wide\"\n        style={{ opacity: labelOpacity, x: labelShift }}\n      >\n        <motion.span\n          className=\"truncate\"\n          style={\n            still\n              ? { color: \"#a3a3a3\" }\n              : {\n                  color: \"transparent\",\n                  backgroundImage:\n                    \"linear-gradient(105deg, #6b6b6b 0%, #6b6b6b 38%, #fafafa 50%, #6b6b6b 62%, #6b6b6b 100%)\",\n                  backgroundSize: \"260% 100%\",\n                  WebkitBackgroundClip: \"text\",\n                  backgroundClip: \"text\",\n                }\n          }\n          animate={still || !inView ? undefined : { backgroundPosition: [\"100% 0%\", \"0% 0%\"] }}\n          transition={{ duration: 2.6, ease: \"linear\", repeat: Infinity, repeatDelay: 0.4 }}\n        >\n          {label}\n        </motion.span>\n      </motion.span>\n\n      {/* Fill — trails the knob; floods solid on commit. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute rounded-full\"\n        style={{\n          top: PAD - 1,\n          bottom: PAD - 1,\n          left: PAD - 1,\n          width: committed ? `calc(100% - ${(PAD - 1) * 2}px)` : fillW,\n          background: committed\n            ? accentColor\n            : `linear-gradient(90deg, ${tint(6)}, ${tint(armed ? 42 : 26)})`,\n          boxShadow: committed ? `0 0 24px -6px ${tint(70)}` : `inset 0 0 0 1px ${tint(14)}`,\n          transition: still ? \"none\" : \"background 240ms ease, box-shadow 240ms ease\",\n        }}\n      />\n\n      {/* Committed label, inked for contrast on the accent. */}\n      <AnimatePresence>\n        {committed && (\n          <motion.span\n            aria-hidden\n            key=\"done\"\n            className=\"pointer-events-none absolute inset-0 flex items-center justify-center pr-12 text-sm font-semibold tracking-wide\"\n            style={{ color: ink }}\n            initial={still ? { opacity: 0 } : { opacity: 0, x: -10 }}\n            animate={{ opacity: 1, x: 0 }}\n            exit={{ opacity: 0 }}\n            transition={still ? QUICK : { duration: 0.32, ease: \"easeOut\", delay: 0.08 }}\n          >\n            {committedLabel}\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      {/* One-shot ring flash on commit. */}\n      {!still && flash > 0 && (\n        <motion.span\n          key={flash}\n          aria-hidden\n          className=\"pointer-events-none absolute -inset-px rounded-full\"\n          style={{ boxShadow: `0 0 0 1.5px ${accentColor}, 0 0 28px ${tint(55)}` }}\n          initial={{ opacity: 0.9, scale: 1 }}\n          animate={{ opacity: 0, scale: 1.06 }}\n          transition={{ duration: 0.7, ease: \"easeOut\" }}\n        />\n      )}\n\n      {/* Knob — the slider itself. */}\n      <motion.div\n        ref={knobRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-describedby={descId}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={pct}\n        aria-valuetext={valueText}\n        aria-disabled={disabled || undefined}\n        aria-readonly={committed || undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onKeyDown={onKeyDown}\n        onBlur={onBlur}\n        className={cn(\n          \"absolute flex items-center justify-center rounded-full outline-none\",\n          \"focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n          disabled ? \"cursor-not-allowed\" : committed ? \"cursor-default\" : dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n        )}\n        style={{\n          top: PAD,\n          left: PAD,\n          width: KNOB,\n          height: KNOB,\n          x,\n          touchAction: \"none\",\n          background: committed\n            ? \"#0a0a0a\"\n            : \"linear-gradient(180deg, #ffffff 0%, #e7e7e7 100%)\",\n          color: committed ? accentColor : \"#171717\",\n          boxShadow: committed\n            ? \"0 1px 2px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(255,255,255,0.08)\"\n            : \"0 4px 14px -2px rgba(0,0,0,0.65), 0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.9)\",\n        }}\n        animate={{ scale: still ? 1 : dragging ? (armed ? 1.06 : 0.97) : 1 }}\n        transition={still ? { duration: 0 } : SPRING}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {committed ? (\n            <motion.svg\n              key=\"check\"\n              aria-hidden\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2.4\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className=\"h-5 w-5\"\n              initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: -30 }}\n              animate={{ opacity: 1, scale: 1, rotate: 0 }}\n              exit={{ opacity: 0 }}\n              transition={still ? QUICK : SPRING}\n            >\n              <motion.path\n                d=\"M5 12.5l4.5 4.5L19 7.5\"\n                initial={still ? false : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={still ? { duration: 0 } : { duration: 0.34, ease: \"easeOut\", delay: 0.06 }}\n              />\n            </motion.svg>\n          ) : (\n            <motion.svg\n              key=\"arrow\"\n              aria-hidden\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2.2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className=\"h-5 w-5\"\n              initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.6 }}\n              transition={still ? QUICK : SPRING}\n            >\n              <path d=\"M6 7l5 5-5 5\" opacity={0.45} />\n              <path d=\"M12 7l5 5-5 5\" />\n            </motion.svg>\n          )}\n        </AnimatePresence>\n      </motion.div>\n\n      <span id={descId} className=\"sr-only\">\n        Drag the handle to the end, or use the arrow keys to reach 100% and press Enter.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {committed ? committedLabel : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  ThrowRow                                  */\n/* -------------------------------------------------------------------------- */\n\n/** One swipe action. The first action on a side is that side's primary. */\nexport interface ThrowAction {\n  /** Visible caption and accessible name. */\n  label: string;\n  /** Icon node (≈18px, uses `currentColor`). */\n  icon?: React.ReactNode;\n  /** Background colour of the action well. @default \"#404040\" (primary) / \"#262626\" */\n  color?: string;\n  /** Called when the action is chosen (after the row collapses, when it collapses). */\n  onAction: () => void;\n}\n\ntype Side = \"leading\" | \"trailing\";\n\n/** Imperative handle for {@link ThrowRow}. */\nexport interface ThrowRowHandle {\n  /** Slide a side's actions into view. */\n  open(side: Side): void;\n  /** Slide the row closed. */\n  close(): void;\n  /** Un-collapse a row whose action has fired and close it. */\n  reset(): void;\n}\n\nexport interface ThrowRowProps {\n  /** Row content — the sliding foreground surface. */\n  children: React.ReactNode;\n  /** Actions revealed by swiping right (they sit on the left edge). */\n  leading?: ThrowAction | ThrowAction[];\n  /** Actions revealed by swiping left (they sit on the right edge). */\n  trailing?: ThrowAction | ThrowAction[];\n  /** A swipe past 60% of the row fires that side's primary action. @default true */\n  fullSwipe?: boolean;\n  /** Collapse the row (height spring) before firing an action. @default true */\n  collapseOnAction?: boolean;\n  /** Idle hint — the row briefly peeks its trailing (else leading) actions until touched. @default false */\n  peek?: boolean;\n  /** Accessible name for the row. Defaults to its text content. */\n  label?: string;\n  /** Class for the outer wrapper. */\n  className?: string;\n  /** Class for the sliding foreground surface. */\n  contentClassName?: string;\n  /** Imperative handle — `{ open(side), close(), reset() }`. */\n  ref?: React.Ref<ThrowRowHandle>;\n}\n\nconst ACTION_W = 76;\nconst FULL = 0.6;\n\nconst asList = (a?: ThrowAction | ThrowAction[]) => (a ? (Array.isArray(a) ? a : [a]) : []);\n\n/** Exponential rubber band: grows quickly at first, saturates near `limit`. */\nconst rubber = (over: number, limit: number) => limit * (1 - Math.exp(-over / (limit * 1.6)));\n\n/**\n * ThrowRow — a list row that slides to reveal actions. Swipe left for trailing\n * actions, right for leading ones; travel past the actions is rubber-banded,\n * the icons scale in as they're uncovered, and a full swipe past 60% fires the\n * side's primary action: the row flies out and collapses on a height spring.\n *\n * Keyboard: the row is focusable; ArrowLeft / ArrowRight reveal actions and\n * move focus into them (Enter activates), Escape or the opposite arrow closes.\n * Reduced motion: no rubber band or springs — short tweens only.\n */\nexport function ThrowRow({\n  children,\n  leading,\n  trailing,\n  fullSwipe = true,\n  collapseOnAction = true,\n  peek = false,\n  label,\n  className,\n  contentClassName,\n  ref,\n}: ThrowRowProps) {\n  const reduced = useReducedMotion();\n  const lead = asList(leading);\n  const trail = asList(trailing);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const rowRef = React.useRef<HTMLDivElement>(null);\n  const leadRef = React.useRef<HTMLDivElement>(null);\n  const trailRef = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(rootRef, { amount: 0.6 });\n  const descId = React.useId();\n\n  const x = useMotionValue(0);\n  const leadW = useTransform(x, (v) => Math.max(0, v));\n  const trailW = useTransform(x, (v) => Math.max(0, -v));\n  const leadIcon = useTransform(leadW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });\n  const trailIcon = useTransform(trailW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });\n  const leadOp = useTransform(leadW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });\n  const trailOp = useTransform(trailW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });\n  // Captions wait until each action has room for them (no cramped half-words).\n  const leadN = useMotionValue(lead.length);\n  const trailN = useMotionValue(trail.length);\n  React.useEffect(() => {\n    leadN.set(lead.length);\n    trailN.set(trail.length);\n  }, [lead.length, trail.length, leadN, trailN]);\n  const cap = ([w, n]: number[]) =>\n    clamp((w / Math.max(1, n) - ACTION_W * 0.62) / (ACTION_W * 0.3), 0, 1);\n  const leadCap = useTransform([leadW, leadN], cap);\n  const trailCap = useTransform([trailW, trailN], cap);\n\n  const [open, setOpen] = React.useState<Side | null>(null);\n  const [armed, setArmed] = React.useState<Side | null>(null);\n  const [firing, setFiring] = React.useState<{ side: Side; index: number } | null>(null);\n  const [collapsed, setCollapsed] = React.useState(false);\n\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const touched = React.useRef(false);\n  const dragged = React.useRef(false);\n  const focusInto = React.useRef<Side | null>(null);\n  const pending = React.useRef<ThrowAction | null>(null);\n  const drag = React.useRef<{\n    id: number;\n    sx: number;\n    sy: number;\n    start: number;\n    decided: boolean;\n    w: number;\n    lx: number;\n    lt: number;\n    v: number;\n  } | null>(null);\n\n  const actionsFor = (s: Side) => (s === \"leading\" ? lead : trail);\n  const sign = (s: Side) => (s === \"leading\" ? 1 : -1);\n\n  const run = React.useCallback(\n    (to: number | number[], t: object) => {\n      anim.current?.stop();\n      anim.current = animate(x, to as number, t);\n    },\n    [x]\n  );\n\n  const close = React.useCallback(() => {\n    setOpen(null);\n    setArmed(null);\n    run(0, reduced ? QUICK : SPRING);\n  }, [reduced, run]);\n\n  const openSide = React.useCallback(\n    (s: Side) => {\n      const n = (s === \"leading\" ? lead : trail).length;\n      if (!n) return;\n      setOpen(s);\n      setArmed(null);\n      run(sign(s) * n * ACTION_W, reduced ? QUICK : SPRING);\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [lead.length, trail.length, reduced, run]\n  );\n\n  const fire = (s: Side, index: number) => {\n    const action = actionsFor(s)[index];\n    if (!action || firing) return;\n    touched.current = true;\n    setFiring({ side: s, index });\n    setArmed(null);\n    if (!collapseOnAction) {\n      action.onAction();\n      window.setTimeout(() => {\n        setFiring(null);\n        close();\n      }, reduced ? 60 : 220);\n      return;\n    }\n    pending.current = action;\n    const w = rowRef.current?.offsetWidth ?? 320;\n    run(sign(s) * w, {\n      ...(reduced ? QUICK : { duration: 0.24, ease: [0.4, 0, 0.6, 1] }),\n      onComplete: () => setCollapsed(true),\n    });\n  };\n\n  const reset = React.useCallback(() => {\n    pending.current = null;\n    setFiring(null);\n    setCollapsed(false);\n    setOpen(null);\n    setArmed(null);\n    anim.current?.stop();\n    x.set(0);\n  }, [x]);\n\n  React.useImperativeHandle(ref, () => ({ open: openSide, close, reset }), [openSide, close, reset]);\n\n  // Focus the revealed panel's first action after a keyboard reveal.\n  React.useEffect(() => {\n    const s = focusInto.current;\n    if (!s || open !== s) return;\n    focusInto.current = null;\n    (s === \"leading\" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>(\"button\")?.focus();\n  }, [open]);\n\n  // Tap / press outside closes an open row.\n  React.useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) close();\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open, close]);\n\n  // Idle peek — teaches that the row swipes.\n  const peekSide: Side | null = trail.length ? \"trailing\" : lead.length ? \"leading\" : null;\n  React.useEffect(() => {\n    if (!peek || reduced || !inView || !peekSide || collapsed) return;\n    let timer = 0;\n    const go = () => {\n      if (touched.current || drag.current || open || firing) return;\n      const d = sign(peekSide) * ACTION_W * 0.85;\n      run([0, d, d, 0], { duration: 1.6, times: [0, 0.3, 0.55, 1], ease: [\"easeOut\", \"linear\", [0.5, 0, 0.2, 1]] });\n      timer = window.setTimeout(go, 6000);\n    };\n    timer = window.setTimeout(go, 1200);\n    return () => window.clearTimeout(timer);\n  }, [peek, reduced, inView, peekSide, collapsed, open, firing, run]);\n\n  React.useEffect(() => () => anim.current?.stop(), []);\n\n  /* ------------------------------- pointer -------------------------------- */\n\n  const shape = (raw: number, w: number) => {\n    const s: Side = raw >= 0 ? \"leading\" : \"trailing\";\n    const n = actionsFor(s).length;\n    const a = Math.abs(raw);\n    if (!n) return reduced ? 0 : sign(s) * rubber(a, 28);\n    const reach = fullSwipe ? w * 0.82 : n * ACTION_W;\n    if (reduced) return sign(s) * Math.min(a, reach);\n    return sign(s) * (a <= reach ? a : reach + rubber(a - reach, 40));\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (firing || e.button !== 0) return;\n    anim.current?.stop();\n    dragged.current = false;\n    drag.current = {\n      id: e.pointerId,\n      sx: e.clientX,\n      sy: e.clientY,\n      start: x.get(),\n      decided: false,\n      w: rowRef.current?.offsetWidth ?? 320,\n      lx: e.clientX,\n      lt: e.timeStamp,\n      v: 0,\n    };\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    const dx = e.clientX - d.sx;\n    const dy = e.clientY - d.sy;\n    if (!d.decided) {\n      if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;\n      if (Math.abs(dy) > Math.abs(dx)) {\n        drag.current = null; // vertical — let the page scroll\n        return;\n      }\n      d.decided = true;\n      dragged.current = true;\n      touched.current = true;\n      e.currentTarget.setPointerCapture(e.pointerId);\n    }\n    const dt = Math.max(1, e.timeStamp - d.lt);\n    d.v = d.v * 0.6 + ((e.clientX - d.lx) / dt) * 1000 * 0.4;\n    d.lx = e.clientX;\n    d.lt = e.timeStamp;\n    const nx = shape(d.start + dx, d.w);\n    x.set(nx);\n    const s: Side = nx >= 0 ? \"leading\" : \"trailing\";\n    const isArmed = fullSwipe && actionsFor(s).length > 0 && Math.abs(nx) > d.w * FULL;\n    setArmed(isArmed ? s : null);\n  };\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    if (!d.decided) return;\n    const cur = x.get();\n    const s: Side = cur >= 0 ? \"leading\" : \"trailing\";\n    const n = actionsFor(s).length;\n    const a = Math.abs(cur);\n    const toward = Math.sign(d.v) === sign(s);\n    if (n && fullSwipe && a > d.w * FULL) return fire(s, 0);\n    if (n && ((a > (n * ACTION_W) / 2 && !(Math.abs(d.v) > 500 && !toward)) || (toward && Math.abs(d.v) > 500)))\n      return openSide(s);\n    close();\n  };\n\n  /* ------------------------------ keyboard -------------------------------- */\n\n  const onRowKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget || firing) return;\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n      e.preventDefault();\n      touched.current = true;\n      const s: Side = e.key === \"ArrowLeft\" ? \"trailing\" : \"leading\";\n      if (open && open !== s) return close();\n      if (!actionsFor(s).length) return;\n      focusInto.current = s;\n      if (open === s) {\n        (s === \"leading\" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>(\"button\")?.focus();\n        focusInto.current = null;\n      } else openSide(s);\n    } else if (e.key === \"Escape\" && open) {\n      e.preventDefault();\n      close();\n    }\n  };\n\n  const onActionKey = (s: Side) => (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    const closeKey = s === \"trailing\" ? \"ArrowRight\" : \"ArrowLeft\";\n    const buttons = Array.from(\n      (s === \"leading\" ? leadRef : trailRef).current?.querySelectorAll<HTMLButtonElement>(\"button\") ?? []\n    );\n    const i = buttons.indexOf(e.currentTarget);\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      close();\n      rowRef.current?.focus();\n    } else if (e.key === closeKey) {\n      e.preventDefault();\n      // Step back toward the content; past the first action, close.\n      if (i > 0) buttons[i - 1].focus();\n      else {\n        close();\n        rowRef.current?.focus();\n      }\n    } else if (e.key === (s === \"trailing\" ? \"ArrowLeft\" : \"ArrowRight\")) {\n      e.preventDefault();\n      buttons[i + 1]?.focus();\n    }\n  };\n\n  /* -------------------------------- render -------------------------------- */\n\n  const panel = (s: Side) => {\n    const acts = actionsFor(s);\n    if (!acts.length) return null;\n    const isLead = s === \"leading\";\n    const hot = firing?.side === s ? firing.index : armed === s ? 0 : -1;\n    const visible = open === s;\n    return (\n      <motion.div\n        ref={isLead ? leadRef : trailRef}\n        inert={!visible}\n        className={cn(\n          \"absolute inset-y-0 flex overflow-hidden\",\n          isLead ? \"left-0 flex-row\" : \"right-0 flex-row-reverse\"\n        )}\n        style={{\n          width: isLead ? leadW : trailW,\n          background: hot >= 0 ? (acts[hot].color ?? \"#404040\") : \"#171717\",\n          transition: reduced ? \"none\" : \"background 160ms ease\",\n        }}\n      >\n        {acts.map((a, i) => {\n          const bg = a.color ?? (i === 0 ? \"#404040\" : \"#262626\");\n          const grow = hot < 0 ? 1 : hot === i ? 1 : 0;\n          return (\n            <button\n              key={a.label}\n              type=\"button\"\n              tabIndex={visible ? 0 : -1}\n              aria-label={a.label}\n              onClick={() => fire(s, i)}\n              onKeyDown={onActionKey(s)}\n              className={cn(\n                \"relative flex min-w-0 basis-0 flex-col items-center justify-center gap-1 overflow-hidden px-1 text-[11px] font-medium outline-none\",\n                \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80\"\n              )}\n              style={{\n                background: bg,\n                color: inkOn(bg),\n                flexGrow: grow,\n                transition: reduced ? \"none\" : \"flex-grow 240ms cubic-bezier(.3,.7,.2,1)\",\n              }}\n            >\n              <motion.span\n                className=\"flex w-[68px] flex-col items-center justify-center gap-1\"\n                style={{\n                  scale: isLead ? leadIcon : trailIcon,\n                  opacity: isLead ? leadOp : trailOp,\n                }}\n              >\n                {a.icon && (\n                  <motion.span\n                    aria-hidden\n                    className=\"flex h-5 w-5 items-center justify-center [&>svg]:h-[18px] [&>svg]:w-[18px]\"\n                    animate={{ scale: !reduced && hot === i ? 1.18 : 1 }}\n                    transition={reduced ? { duration: 0 } : SPRING}\n                  >\n                    {a.icon}\n                  </motion.span>\n                )}\n                <motion.span\n                  className=\"truncate\"\n                  style={{ opacity: hot === i ? 1 : isLead ? leadCap : trailCap }}\n                >\n                  {a.label}\n                </motion.span>\n              </motion.span>\n            </button>\n          );\n        })}\n      </motion.div>\n    );\n  };\n\n  const hasActions = lead.length + trail.length > 0;\n  const hintText = [\n    trail.length ? `Left arrow: ${trail.map((a) => a.label).join(\", \")}` : null,\n    lead.length ? `Right arrow: ${lead.map((a) => a.label).join(\", \")}` : null,\n  ]\n    .filter(Boolean)\n    .join(\". \");\n\n  return (\n    <motion.div\n      ref={rootRef}\n      data-crucible=\"throw-row\"\n      className={cn(\"relative overflow-hidden\", className)}\n      initial={false}\n      animate={collapsed ? { height: 0, opacity: 0 } : { height: \"auto\", opacity: 1 }}\n      transition={reduced ? { duration: 0.12 } : { height: SOFT, opacity: { duration: 0.2 } }}\n      onAnimationComplete={() => {\n        if (!collapsed || !pending.current) return;\n        const a = pending.current;\n        pending.current = null;\n        a.onAction();\n      }}\n    >\n      {panel(\"leading\")}\n      {panel(\"trailing\")}\n      <motion.div\n        ref={rowRef}\n        tabIndex={collapsed ? -1 : 0}\n        role=\"group\"\n        aria-roledescription=\"swipeable row\"\n        aria-label={label}\n        aria-describedby={hasActions ? descId : undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onClickCapture={(e) => {\n          if (dragged.current) {\n            e.preventDefault();\n            e.stopPropagation();\n            dragged.current = false;\n          } else if (open) {\n            e.stopPropagation();\n            close();\n          }\n        }}\n        onKeyDown={onRowKey}\n        className={cn(\n          \"relative z-10 touch-pan-y select-none bg-neutral-900 outline-none\",\n          \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60\",\n          contentClassName\n        )}\n        style={{ x }}\n      >\n        {children}\n      </motion.div>\n      {hasActions && (\n        <span id={descId} className=\"sr-only\">\n          {hintText}. Escape closes.\n        </span>\n      )}\n    </motion.div>\n  );\n}\n"
    }
  ]
}