{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "piston",
  "type": "registry:ui",
  "title": "Piston",
  "description": "A stateful submit button that runs its async lifecycle like a piston stroke: label and spinner slide along one axis while the width breathes, a check draws itself on success, and rejection plays a shake-and-retry beat. Pass an async onClick and it owns the state machine — aria-busy, live announcements, and real disabling included.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/buttons/piston/piston.tsx",
      "type": "registry:ui",
      "target": "components/crucible/piston.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, type HTMLMotionProps, type Transition } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PistonState = \"idle\" | \"pending\" | \"success\" | \"error\";\n\nexport interface PistonProps\n  extends Omit<HTMLMotionProps<\"button\">, \"onClick\" | \"children\" | \"animate\" | \"transition\"> {\n  /**\n   * Async click handler — return a promise and Piston runs the whole\n   * lifecycle for you: pending while in flight, success/error on settle,\n   * back to idle after the dwell. A sync handler leaves the button idle.\n   */\n  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;\n  /**\n   * Controlled state. When set, the internal machine is off: Piston renders\n   * exactly this state and you own every transition (onClick still fires).\n   */\n  state?: PistonState;\n  /** Idle label. @default \"Submit\" */\n  children?: React.ReactNode;\n  /** Pending content. @default a neutral spinner */\n  pendingContent?: React.ReactNode;\n  /** Success content. @default a self-drawing check */\n  successContent?: React.ReactNode;\n  /** Error / retry label — the button stays live so a click retries. @default \"Retry\" */\n  errorContent?: React.ReactNode;\n  /** Contract the button toward a circle while pending. @default false */\n  contractToCircle?: boolean;\n  /** Seconds the success state dwells before the label returns. @default 1.6 */\n  successDwell?: number;\n  /** Error shake amplitude multiplier — 0 disables the shake. @default 1 */\n  shakeIntensity?: number;\n  /** Spring for the axial content slide and the width breath. @default { stiffness: 520, damping: 34 } */\n  spring?: { stiffness?: number; damping?: number };\n  /** Really disable the button (not just visually) while pending. @default true */\n  disabledWhilePending?: boolean;\n  /** The only saturated moments. @default { success: \"#34d399\", error: \"#f87171\" } */\n  colors?: { success?: string; error?: string };\n  /** Button size. @default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n\nconst SIZES = {\n  sm: { height: 36, padX: 18, radius: 8, text: \"text-xs\" },\n  md: { height: 44, padX: 24, radius: 10, text: \"text-sm\" },\n  lg: { height: 52, padX: 30, radius: 12, text: \"text-base\" },\n} as const;\n\n/** Draws itself with a single stroke, then lands with a 1-frame settle. */\nfunction Check({ color, reduced }: { color: string; reduced: boolean }) {\n  return (\n    <motion.svg\n      aria-hidden\n      viewBox=\"0 0 24 24\"\n      width=\"1.25em\"\n      height=\"1.25em\"\n      fill=\"none\"\n      className=\"block\"\n      initial={reduced ? false : { scale: 0.9 }}\n      animate={reduced ? { scale: 1 } : { scale: [0.9, 1.07, 1] }}\n      transition={reduced ? { duration: 0 } : { duration: 0.45, times: [0, 0.88, 1], ease: \"easeOut\" }}\n    >\n      <motion.path\n        d=\"M5 12.5 L10 17.5 L19 7\"\n        stroke={color}\n        strokeWidth={2.6}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={reduced ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={reduced ? { duration: 0 } : { duration: 0.32, ease: [0.65, 0, 0.35, 1] }}\n      />\n    </motion.svg>\n  );\n}\n\n/** Neutral spinner — a rotating ring, or an opacity pulse under reduced motion. */\nfunction Spinner({ reduced }: { reduced: boolean }) {\n  return reduced ? (\n    <motion.span\n      aria-hidden\n      className=\"block h-[1.1em] w-[1.1em] rounded-full border-2 border-current\"\n      animate={{ opacity: [0.3, 0.9, 0.3] }}\n      transition={{ duration: 1.4, repeat: Infinity, ease: \"easeInOut\" }}\n    />\n  ) : (\n    <motion.span\n      aria-hidden\n      className=\"block h-[1.1em] w-[1.1em] rounded-full border-2 border-current border-t-transparent opacity-80\"\n      animate={{ rotate: 360 }}\n      transition={{ duration: 0.75, repeat: Infinity, ease: \"linear\" }}\n    />\n  );\n}\n\n/**\n * Piston — a stateful submit button that runs its async lifecycle like a\n * piston stroke: the label slides out along one axis as the spinner slides\n * in, the button's width breathing around each state's content — mechanical\n * and axial, never a fade-in-place. On resolve the spinner gives way to a\n * check that draws itself in a single stroke and lands with a 1-frame\n * settle; rejection plays a distinct beat — a shake and an error tint —\n * and leaves a live retry label. Success and error are the only saturated\n * moments and both cool back to the neutral chrome. Honest a11y: aria-busy\n * while pending, a polite live announcement on resolution, and a real\n * disabled state. Reduced motion: instant state swaps, no shake, spinner\n * as an opacity pulse only.\n */\nexport function Piston({\n  onClick,\n  state,\n  children,\n  pendingContent,\n  successContent,\n  errorContent,\n  contractToCircle = false,\n  successDwell = 1.6,\n  shakeIntensity = 1,\n  spring,\n  disabledWhilePending = true,\n  colors,\n  size = \"md\",\n  className,\n  style,\n  disabled,\n  ...rest\n}: PistonProps) {\n  const reducedMotion = useReducedMotion();\n  const successColor = colors?.success ?? \"#34d399\";\n  const errorColor = colors?.error ?? \"#f87171\";\n  const stiffness = spring?.stiffness ?? 520;\n  const damping = spring?.damping ?? 34;\n  const dims = SIZES[size];\n\n  const controlled = state !== undefined;\n  const [internal, setInternal] = React.useState<PistonState>(\"idle\");\n  const status: PistonState = controlled ? state : internal;\n  // Monotonic run token: a click supersedes any in-flight settle or dwell.\n  const runId = React.useRef(0);\n  const dwellTimer = React.useRef<number | null>(null);\n\n  const clearDwell = React.useCallback(() => {\n    if (dwellTimer.current !== null) {\n      window.clearTimeout(dwellTimer.current);\n      dwellTimer.current = null;\n    }\n  }, []);\n  React.useEffect(() => clearDwell, [clearDwell]);\n\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    // Never re-enter an in-flight run, even when disabledWhilePending is off.\n    if (status === \"pending\") return;\n    const result = onClick?.(event);\n    if (controlled) return;\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      const id = ++runId.current;\n      clearDwell();\n      setInternal(\"pending\");\n      (result as Promise<unknown>).then(\n        () => {\n          if (id !== runId.current) return;\n          setInternal(\"success\");\n          dwellTimer.current = window.setTimeout(() => {\n            if (id === runId.current) setInternal(\"idle\");\n          }, successDwell * 1000);\n        },\n        () => {\n          if (id === runId.current) setInternal(\"error\");\n        }\n      );\n    }\n  };\n\n  const content: Record<PistonState, React.ReactNode> = {\n    idle: children ?? \"Submit\",\n    pending: pendingContent ?? <Spinner reduced={reducedMotion} />,\n    success: successContent ?? <Check color={successColor} reduced={reducedMotion} />,\n    error: errorContent ?? \"Retry\",\n  };\n\n  const announcement =\n    status === \"success\"\n      ? typeof successContent === \"string\"\n        ? successContent\n        : \"Success\"\n      : status === \"error\"\n        ? typeof errorContent === \"string\"\n          ? `Failed — ${errorContent.toLowerCase()}`\n          : \"Something went wrong — press to retry\"\n        : \"\";\n\n  const contracted = contractToCircle && status === \"pending\";\n  const springT: Transition = reducedMotion\n    ? { duration: 0 }\n    : { type: \"spring\", stiffness, damping };\n\n  // The error beat: a mechanical lateral shake, scaled by intensity.\n  const amp = 6 * shakeIntensity;\n  const shakeX =\n    status === \"error\" && !reducedMotion && shakeIntensity > 0\n      ? [0, -amp, amp * 0.85, -amp * 0.6, amp * 0.35, 0]\n      : 0;\n\n  return (\n    <motion.button\n      data-crucible=\"piston\"\n      layout={!reducedMotion}\n      aria-busy={status === \"pending\" || undefined}\n      disabled={disabled || (disabledWhilePending && status === \"pending\")}\n      whileTap={reducedMotion || status === \"pending\" ? undefined : { scale: 0.97 }}\n      animate={{ x: shakeX }}\n      transition={{ layout: springT, x: { duration: 0.4, ease: \"easeInOut\" } }}\n      className={cn(\n        \"relative isolate inline-flex items-center justify-center overflow-hidden\",\n        \"border border-neutral-800 bg-neutral-900 font-medium text-neutral-100\",\n        dims.text,\n        \"select-none transition-colors duration-200\",\n        \"hover:border-neutral-700 hover:bg-neutral-800\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n        \"disabled:cursor-not-allowed\",\n        className\n      )}\n      style={{\n        height: dims.height,\n        width: contracted ? dims.height : \"auto\",\n        paddingLeft: contracted ? 0 : dims.padX,\n        paddingRight: contracted ? 0 : dims.padX,\n        borderRadius: contracted ? dims.height / 2 : dims.radius,\n        ...style,\n      }}\n      onClick={handleClick}\n      {...rest}\n    >\n      {/* Soft top light so the neutral face never reads as a flat slab. */}\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          background: \"radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,0.06), transparent 55%)\",\n          boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.07), inset 0 -1px 0 rgba(0,0,0,0.35)\",\n        }}\n      />\n      {/* Success tint: flashes saturated on the settle, then cools to neutral. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${successColor} 70%, transparent), inset 0 -10px 22px -12px color-mix(in oklab, ${successColor} 55%, transparent), 0 0 16px -6px color-mix(in oklab, ${successColor} 60%, transparent)`,\n        }}\n        initial={false}\n        animate={\n          status === \"success\"\n            ? { opacity: reducedMotion ? 0.4 : [0, 0.95, 0.25] }\n            : { opacity: 0 }\n        }\n        transition={\n          status === \"success\" && !reducedMotion\n            ? { duration: 0.9, times: [0, 0.3, 1], ease: \"easeOut\" }\n            : { duration: 0.25 }\n        }\n      />\n      {/* Error tint: a distinct flash that cools to a faint standing warning. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${errorColor} 70%, transparent), inset 0 -10px 22px -12px color-mix(in oklab, ${errorColor} 50%, transparent), 0 0 16px -6px color-mix(in oklab, ${errorColor} 55%, transparent)`,\n        }}\n        initial={false}\n        animate={\n          status === \"error\"\n            ? { opacity: reducedMotion ? 0.45 : [0, 1, 0.35] }\n            : { opacity: 0 }\n        }\n        transition={\n          status === \"error\" && !reducedMotion\n            ? { duration: 0.9, times: [0, 0.25, 1], ease: \"easeOut\" }\n            : { duration: 0.25 }\n        }\n      />\n\n      {/* Axial viewport: content strokes vertically through, clipped at the lips. */}\n      <motion.span\n        layout={!reducedMotion}\n        className=\"relative z-10 flex h-full items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={status}\n            className=\"flex items-center justify-center whitespace-nowrap\"\n            style={\n              status === \"error\"\n                ? { color: `color-mix(in oklab, ${errorColor} 55%, white)` }\n                : undefined\n            }\n            initial={reducedMotion ? { y: 0 } : { y: \"130%\" }}\n            animate={{ y: 0 }}\n            exit={reducedMotion ? { y: 0, transition: { duration: 0 } } : { y: \"-130%\" }}\n            transition={springT}\n          >\n            {content[status]}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n\n      {/* Honest resolution announcement for assistive tech. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </motion.button>\n  );\n}\n"
    }
  ]
}