{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-undo-fuse",
  "title": "Toast Undo Fuse",
  "description": "Undo toast whose remaining lifetime is a literal fuse: a hairline along the bottom edge burns right-to-left with an ember at the front, answering \"when does this go away\" without a numeric countdown.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/toast-undo-fuse/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ShortFuse — an undo toast whose remaining lifetime IS its border. A 1px\n// hairline along the bottom edge is an SVG line whose stroke-dashoffset\n// animates linearly from 0 -> 100 (pathLength-normalized) over `duration`, so\n// the unburnt portion visibly shortens from the right edge toward the left. A\n// 3px ember dot tracks the burn front via a mirrored `left` animation. Both\n// run on the Web Animations API so pause/resume is exactly `.pause()`/`.play()`\n// — no manual timers to desync, and `.currentTime` stays truthful for free.\n//\n// Hover or keyboard focus pauses both animations and springs the ember's\n// scale down (k=300, zeta=0.7) — a physical pinch, not an invisible timer\n// stopping somewhere off-screen. Escape or the fuse running out dismisses.\n// Undo reads the current burn progress, cancels the forward animations, and\n// plays a reverse pair (same duration/4, ease-out-expo) back to unburnt\n// before the toast exits. Colors: --border for the unburnt line, --foreground\n// for the ember, --ns-accent only on the Undo button — the toast surface is\n// --background with a 1px --border and 12px radius.\n//\n// prefers-reduced-motion: the line depletes in discrete steps (stepped\n// easing, no ember animation at all — it's hidden), and Undo snaps back\n// instantly rather than replaying the reverse-burn.\n// ---------------------------------------------------------------------------\n\nconst EASE_OUT_EXPO = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst SPRING_K = 300; // s^-2 — ember pinch stiffness\nconst SPRING_ZETA = 0.7; // damping ratio, slight settle overshoot\nconst EMBER_REST_SCALE = 1;\nconst EMBER_PAUSE_SCALE = 0.45;\nconst EXIT_MS = 180;\n\ntype Phase = \"burning\" | \"reversing\" | \"exiting\" | \"gone\";\n\nfunction prefersReducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\nfunction animationElapsedMs(anim: Animation | null): number {\n  const ct = anim?.currentTime;\n  return typeof ct === \"number\" ? ct : 0;\n}\n\nexport interface ShortFuseProps {\n  /** The undo-able message, e.g. \"Message archived\". Required. */\n  message: string;\n  /** Label on the action button. Default \"Undo\". */\n  actionLabel?: string;\n  /** Total fuse life in ms before it auto-dismisses. Default 6000. */\n  duration?: number;\n  /** Fires once the reverse-burn and exit finish after Undo is pressed. */\n  onUndo?: () => void;\n  /** Fires once the exit finishes after the fuse burns out, or Escape. */\n  onDismiss?: () => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function ShortFuse({\n  message,\n  actionLabel = \"Undo\",\n  duration = 6000,\n  onUndo,\n  onDismiss,\n  className = \"\",\n}: ShortFuseProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const lineRef = useRef<SVGLineElement>(null);\n  const emberRef = useRef<HTMLSpanElement>(null);\n\n  const lineAnimRef = useRef<Animation | null>(null);\n  const emberAnimRef = useRef<Animation | null>(null);\n  const phaseRef = useRef<Phase>(\"burning\");\n\n  // ember spring (rAF), independent of the WAAPI position animation\n  const scaleRef = useRef(EMBER_REST_SCALE);\n  const scaleVelRef = useRef(0);\n  const springTargetRef = useRef(EMBER_REST_SCALE);\n  const springRafRef = useRef(0);\n  const springLastRef = useRef(0);\n\n  // latest callbacks by ref so the mount effect never needs to re-run when a\n  // caller passes a fresh inline function every render\n  const onUndoRef = useRef(onUndo);\n  const onDismissRef = useRef(onDismiss);\n  useEffect(() => {\n    onUndoRef.current = onUndo;\n  }, [onUndo]);\n  useEffect(() => {\n    onDismissRef.current = onDismiss;\n  }, [onDismiss]);\n\n  const [phase, setPhase] = useState<Phase>(\"burning\");\n  const [description, setDescription] = useState(\n    () => `Auto-dismisses in ${Math.round(duration / 1000)}s unless undone.`\n  );\n\n  const autoId = useId();\n  const descId = `sf-desc-${autoId}`;\n\n  const setPhaseBoth = useCallback((p: Phase) => {\n    phaseRef.current = p;\n    setPhase(p);\n  }, []);\n\n  // -- ember spring loop -----------------------------------------------------\n  const wakeSpring = useCallback(() => {\n    if (springRafRef.current) return;\n    springLastRef.current = performance.now();\n    const step = (now: number) => {\n      const dt = Math.min(Math.max((now - springLastRef.current) / 1000, 0), 0.032);\n      springLastRef.current = now;\n      const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n      const target = springTargetRef.current;\n      const x = scaleRef.current;\n      const v = scaleVelRef.current;\n      const a = SPRING_K * (target - x) - c * v;\n      const nv = v + a * dt;\n      const nx = x + nv * dt;\n      scaleRef.current = nx;\n      scaleVelRef.current = nv;\n      const el = emberRef.current;\n      if (el) el.style.transform = `translate(-50%, -50%) scale(${nx})`;\n      if (Math.abs(target - nx) > 0.002 || Math.abs(nv) > 0.01) {\n        springRafRef.current = requestAnimationFrame(step);\n      } else {\n        scaleRef.current = target;\n        if (el) el.style.transform = `translate(-50%, -50%) scale(${target})`;\n        springRafRef.current = 0;\n      }\n    };\n    springRafRef.current = requestAnimationFrame(step);\n  }, []);\n\n  // -- exit + settle ----------------------------------------------------------\n  const finishExit = useCallback(\n    (reason: \"undone\" | \"dismissed\") => {\n      setPhaseBoth(\"gone\");\n      if (reason === \"undone\") onUndoRef.current?.();\n      else onDismissRef.current?.();\n    },\n    [setPhaseBoth]\n  );\n\n  const runExit = useCallback(\n    (reason: \"undone\" | \"dismissed\") => {\n      setPhaseBoth(\"exiting\");\n      const root = rootRef.current;\n      if (!root || typeof root.animate !== \"function\") {\n        finishExit(reason);\n        return;\n      }\n      const reduced = prefersReducedMotion();\n      const anim = root.animate(\n        [\n          { transform: \"translateX(0)\", opacity: 1 },\n          { transform: \"translateX(12px)\", opacity: 0 },\n        ],\n        { duration: reduced ? 1 : EXIT_MS, easing: \"ease-out\", fill: \"forwards\" }\n      );\n      anim.finished.then(() => finishExit(reason)).catch(() => finishExit(reason));\n    },\n    [finishExit, setPhaseBoth]\n  );\n\n  // -- mount: set up the two forward burn animations --------------------------\n  useEffect(() => {\n    const line = lineRef.current;\n    const ember = emberRef.current;\n    if (!line) return;\n    const reduced = prefersReducedMotion();\n\n    line.style.strokeDasharray = \"100\";\n    line.style.strokeDashoffset = \"0\";\n    const easing = reduced ? \"steps(8, end)\" : \"linear\";\n\n    const lineAnim = line.animate(\n      [{ strokeDashoffset: \"0\" }, { strokeDashoffset: \"100\" }],\n      { duration, easing, fill: \"forwards\" }\n    );\n    lineAnimRef.current = lineAnim;\n\n    let emberAnim: Animation | null = null;\n    if (!reduced && ember) {\n      emberAnim = ember.animate([{ left: \"100%\" }, { left: \"0%\" }], {\n        duration,\n        easing,\n        fill: \"forwards\",\n      });\n      emberAnimRef.current = emberAnim;\n    }\n\n    let cancelled = false;\n    lineAnim.finished\n      .then(() => {\n        if (!cancelled && phaseRef.current === \"burning\") runExit(\"dismissed\");\n      })\n      .catch(() => {});\n\n    return () => {\n      cancelled = true;\n      lineAnim.cancel();\n      emberAnim?.cancel();\n      if (springRafRef.current) {\n        cancelAnimationFrame(springRafRef.current);\n        springRafRef.current = 0;\n      }\n    };\n    // duration is the only value this timeline actually depends on; onUndo /\n    // onDismiss are read from refs (via runExit -> finishExit) so a fresh\n    // inline prop from the caller never restarts the burn.\n  }, [duration, runExit]);\n\n  // -- pause / resume on hover or focus ----------------------------------------\n  const updateDescription = useCallback(\n    (paused: boolean) => {\n      const remaining = Math.max(\n        0,\n        Math.round((duration - animationElapsedMs(lineAnimRef.current)) / 1000)\n      );\n      setDescription(\n        paused\n          ? `Paused. ${remaining}s remaining unless undone.`\n          : `Auto-dismisses in ${remaining}s unless undone.`\n      );\n    },\n    [duration]\n  );\n\n  const setPaused = useCallback(\n    (paused: boolean) => {\n      if (phaseRef.current !== \"burning\") return;\n      if (prefersReducedMotion()) return; // stepped depletion has no pause affordance\n      lineAnimRef.current?.[paused ? \"pause\" : \"play\"]();\n      emberAnimRef.current?.[paused ? \"pause\" : \"play\"]();\n      springTargetRef.current = paused ? EMBER_PAUSE_SCALE : EMBER_REST_SCALE;\n      wakeSpring();\n      updateDescription(paused);\n    },\n    [updateDescription, wakeSpring]\n  );\n\n  const handleUndo = useCallback(() => {\n    if (phaseRef.current !== \"burning\") return;\n    const reduced = prefersReducedMotion();\n    const elapsed = animationElapsedMs(lineAnimRef.current);\n    const dashoffsetNow = Math.min(100, Math.max(0, (elapsed / duration) * 100));\n\n    lineAnimRef.current?.cancel();\n    emberAnimRef.current?.cancel();\n    setPhaseBoth(\"reversing\");\n\n    const line = lineRef.current;\n    const ember = emberRef.current;\n    const revDuration = reduced ? 1 : Math.max(140, elapsed / 4);\n    const revEasing = reduced ? \"linear\" : EASE_OUT_EXPO;\n\n    const revLine = line?.animate(\n      [{ strokeDashoffset: `${dashoffsetNow}` }, { strokeDashoffset: \"0\" }],\n      { duration: revDuration, easing: revEasing, fill: \"forwards\" }\n    );\n    if (!reduced && ember) {\n      ember.animate(\n        [{ left: `${100 - dashoffsetNow}%` }, { left: \"100%\" }],\n        { duration: revDuration, easing: revEasing, fill: \"forwards\" }\n      );\n    }\n    springTargetRef.current = EMBER_REST_SCALE;\n    wakeSpring();\n\n    if (revLine) {\n      revLine.finished.then(() => runExit(\"undone\")).catch(() => runExit(\"undone\"));\n    } else {\n      runExit(\"undone\");\n    }\n  }, [duration, runExit, setPhaseBoth, wakeSpring]);\n\n  const handleKeyDown = useCallback(\n    (e: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (e.key === \"Escape\" && phaseRef.current === \"burning\") {\n        e.stopPropagation();\n        lineAnimRef.current?.pause();\n        emberAnimRef.current?.pause();\n        runExit(\"dismissed\");\n      }\n    },\n    [runExit]\n  );\n\n  if (phase === \"gone\") return null;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"status\"\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n      aria-describedby={descId}\n      tabIndex={0}\n      onMouseEnter={() => setPaused(true)}\n      onMouseLeave={() => setPaused(false)}\n      onFocus={() => setPaused(true)}\n      onBlur={(e) => {\n        if (!rootRef.current?.contains(e.relatedTarget as Node)) setPaused(false);\n      }}\n      onKeyDown={handleKeyDown}\n      className={`relative w-full max-w-sm overflow-hidden rounded-[12px] border border-border bg-background px-4 py-3 pb-4 text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-border ${className}`}\n    >\n      <div className=\"flex items-start gap-3\">\n        <p className=\"min-w-0 flex-1 text-sm leading-snug\">{message}</p>\n        <button\n          type=\"button\"\n          data-toast-undo-fuse-undo=\"\"\n          onClick={handleUndo}\n          className=\"shrink-0 cursor-pointer rounded-[6px] px-2 py-1 text-xs font-medium tracking-wide text-ns-accent transition-opacity duration-150 hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        >\n          {actionLabel}\n        </button>\n      </div>\n\n      <span id={descId} className=\"sr-only\">\n        {description}\n      </span>\n\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[3px] overflow-visible\"\n      >\n        <svg\n          className=\"absolute inset-0 h-full w-full overflow-visible\"\n          viewBox=\"0 0 100 3\"\n          preserveAspectRatio=\"none\"\n        >\n          <line\n            ref={lineRef}\n            x1=\"0\"\n            y1=\"1.5\"\n            x2=\"100\"\n            y2=\"1.5\"\n            stroke=\"var(--border)\"\n            strokeWidth=\"1\"\n            vectorEffect=\"non-scaling-stroke\"\n            pathLength={100}\n          />\n        </svg>\n        <span\n          ref={emberRef}\n          className=\"absolute top-1/2 h-[3px] w-[3px] rounded-full bg-foreground motion-reduce:hidden\"\n          style={{ left: \"100%\", transform: \"translate(-50%, -50%) scale(1)\" }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/toast-undo-fuse.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "toast",
      "notification",
      "undo",
      "timer",
      "svg",
      "spring",
      "micro-interaction",
      "feedback"
    ],
    "instruction": "A single undo toast (role=status, aria-live=polite, aria-atomic) whose remaining lifetime is rendered as its own bottom border rather than a digit. The border is a full-width SVG <line> with pathLength=100 so stroke-dasharray/stroke-dashoffset are resolution-independent; a Web Animations API animation drives stroke-dashoffset linearly from 0 to 100 over `duration` (default 6000ms), which shortens the visible (unburnt, --border colored) segment from the right edge toward the left as time elapses. A sibling absolutely-positioned span is the 3px ember — --foreground colored, 1:1 vertically centered on the line — animated via a mirrored `left` WAAPI animation (100% to 0%) so it always sits exactly at the current burn front. Both animations are created once at mount and never restarted by prop identity churn (onUndo/onDismiss are read through refs); pausing is `.pause()` on both Animation objects, which freezes `.currentTime` for free, so the accessible description (a visually-hidden span bound via aria-describedby) can read genuine remaining seconds off the animation clock instead of a separate drifting interval. Hovering the toast or focusing it (Tab reaches the toast itself before the Undo button, and it is a real focus target, not a visual-only affordance) pauses both animations and springs the ember's scale down to 0.45 with an underdamped 1D spring (k=300 s^-2, zeta=0.7, rAF-driven, sleeps once settled) — the pause is a physical pinch of the fuse, not an invisible timer stopping somewhere off-screen. Undo is a real <button> (--ns-accent text, the only place --ns-accent appears); clicking it reads the current burn progress off the forward animation's currentTime, cancels both forward animations, and plays a reverse pair back to the unburnt state at 4x speed (duration/4, floored at 140ms) with ease-out-expo, then runs a 180ms exit translate/fade before calling onUndo. Escape, or the fuse simply running out, skips the reverse and runs the same exit before calling onDismiss. The toast surface is --background with a 1px --border and 12px radius; overflow-hidden lets the corner radius clip the hairline's ends cleanly. prefers-reduced-motion: the line animation switches to stepped easing (steps(8, end)) so it depletes in discrete jumps instead of a continuous sweep, the ember is not rendered or animated at all, hover/focus pausing is disabled (nothing to pause), and Undo snaps back to unburnt in a single frame instead of replaying the reverse-burn — the toast stays fully usable, just without any of the physical motion. Props: message (required), actionLabel (default \"Undo\"), duration, onUndo, onDismiss. Zero dependencies beyond React."
  },
  "type": "registry:ui"
}