{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-activity","type":"registry:ui","title":"Live Activity","description":"The system's ongoing work, worn as a small object.","dependencies":["motion"],"categories":["notification"],"docs":"https://www.interior.dev/docs/live-activity","files":[{"path":"registry/interior/live-activity.tsx","type":"registry:ui","target":"components/interior/live-activity.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst SURFACE = { type: \"spring\", stiffness: 420, damping: 36, mass: 0.9 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst SMALL = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\nconst FILL = { type: \"spring\", stiffness: 210, damping: 34, mass: 0.9 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst LEAVE = [0.4, 0, 1, 1] as const;\nconst DRAW = { duration: 0.3, ease: EASE } as const;\nconst INSTANT = { duration: 0 } as const;\nconst SPIN = { duration: 0.85, ease: \"linear\", repeat: Infinity } as const;\n\nconst PEEK_FOR = 2600;\nconst LEAVE_DELAY = 160;\n\nconst face = (on: boolean) => (on ? \"\" : \"pointer-events-none\");\n\nconst useIsoLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type ActivityPhase = \"running\" | \"success\" | \"error\";\n\nexport type Activity = {\n  id: string;\n  title: string;\n  detail?: string;\n  progress?: number | null;\n  phase: ActivityPhase;\n  action?: { label: string; onClick: () => void };\n};\n\nexport type ActivityInput = {\n  title: string;\n  detail?: string;\n  progress?: number | null;\n};\n\nexport type UseLiveActivityOptions = {\n  linger?: number;\n};\n\nexport function useLiveActivity({ linger = 2000 }: UseLiveActivityOptions = {}) {\n  const [activity, setActivity] = useState<Activity | null>(null);\n  const seq = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const clear = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n  }, []);\n\n  const start = useCallback(\n    (input: ActivityInput) => {\n      clear();\n      seq.current += 1;\n      const id = `activity-${seq.current}`;\n      setActivity({ progress: null, ...input, id, phase: \"running\" });\n      return id;\n    },\n    [clear],\n  );\n\n  const update = useCallback((patch: Partial<ActivityInput>) => {\n    setActivity((prev) => (prev ? { ...prev, ...patch } : prev));\n  }, []);\n\n  const dismiss = useCallback(() => {\n    clear();\n    setActivity(null);\n  }, [clear]);\n\n  const succeed = useCallback(\n    (patch?: Partial<ActivityInput>) => {\n      setActivity((prev) =>\n        prev ? { ...prev, ...patch, phase: \"success\", progress: 1 } : prev,\n      );\n      clear();\n      timer.current = setTimeout(() => {\n        timer.current = null;\n        setActivity(null);\n      }, linger);\n    },\n    [clear, linger],\n  );\n\n  const fail = useCallback(\n    (patch?: Partial<ActivityInput>, action?: Activity[\"action\"]) => {\n      clear();\n      setActivity((prev) =>\n        prev ? { ...prev, ...patch, phase: \"error\", action } : prev,\n      );\n    },\n    [clear],\n  );\n\n  useEffect(() => clear, [clear]);\n\n  return { activity, start, update, succeed, fail, dismiss };\n}\n\nexport type UseLiveActivityReturn = ReturnType<typeof useLiveActivity>;\n\nexport type LiveActivityProps = {\n  activity: Activity | null;\n  onDismiss?: () => void;\n  width?: number;\n  dismissLabel?: string;\n  label?: string;\n  className?: string;\n};\n\nexport function LiveActivity({\n  activity,\n  onDismiss,\n  width = 300,\n  dismissLabel = \"Dismiss activity\",\n  label = \"Activity\",\n  className = \"\",\n}: LiveActivityProps) {\n  const reduced = useReducedMotion() === true;\n\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [peeking, setPeeking] = useState(false);\n  const [spoken, setSpoken] = useState(\"\");\n\n  const compactRef = useRef<HTMLDivElement>(null);\n  const expandedRef = useRef<HTMLDivElement>(null);\n  const sizes = useRef({ c: { w: 0, h: 0 }, e: { w: 0, h: 0 } });\n  const [dims, setDims] = useState<{ w: number; h: number } | null>(null);\n\n  const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const peekTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const said = useRef<Set<string>>(new Set());\n\n  const phase = activity?.phase ?? \"running\";\n  const expanded =\n    activity !== null && (hovered || focused || peeking || phase === \"error\");\n\n  const apply = useCallback((open: boolean) => {\n    const target = open ? sizes.current.e : sizes.current.c;\n    if (target.w === 0 || target.h === 0) return;\n    setDims((prev) =>\n      prev &&\n      Math.abs(prev.w - target.w) < 0.5 &&\n      Math.abs(prev.h - target.h) < 0.5\n        ? prev\n        : { w: target.w, h: target.h },\n    );\n  }, []);\n\n  useIsoLayoutEffect(() => {\n    if (!activity) return;\n    const read = () => {\n      const c = compactRef.current;\n      const e = expandedRef.current;\n      if (c) sizes.current.c = { w: c.offsetWidth, h: c.offsetHeight };\n      if (e) sizes.current.e = { w: e.offsetWidth, h: e.offsetHeight };\n      apply(expanded);\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    if (compactRef.current) observer.observe(compactRef.current);\n    if (expandedRef.current) observer.observe(expandedRef.current);\n    return () => observer.disconnect();\n  }, [activity, expanded, apply]);\n\n  useEffect(() => {\n    if (!activity) {\n      setPeeking(false);\n      setHovered(false);\n      setFocused(false);\n      setDims(null);\n      return;\n    }\n    setPeeking(true);\n    if (peekTimer.current) clearTimeout(peekTimer.current);\n    peekTimer.current = setTimeout(() => {\n      peekTimer.current = null;\n      setPeeking(false);\n    }, PEEK_FOR);\n  }, [activity?.id, activity?.phase, activity]);\n\n  useEffect(\n    () => () => {\n      if (leaveTimer.current) clearTimeout(leaveTimer.current);\n      if (peekTimer.current) clearTimeout(peekTimer.current);\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (!activity) return;\n    const key = `${activity.id}:${activity.phase}`;\n    if (said.current.has(key)) return;\n    if (said.current.size > 64) said.current.clear();\n    said.current.add(key);\n    setSpoken(\n      activity.phase === \"running\"\n        ? `${activity.title} started.`\n        : activity.phase === \"success\"\n          ? `${activity.title} finished.`\n          : `${activity.title} failed.`,\n    );\n  }, [activity]);\n\n  const enter = () => {\n    if (leaveTimer.current) clearTimeout(leaveTimer.current);\n    leaveTimer.current = null;\n    setHovered(true);\n  };\n\n  const leave = () => {\n    if (leaveTimer.current) clearTimeout(leaveTimer.current);\n    leaveTimer.current = setTimeout(() => {\n      leaveTimer.current = null;\n      setHovered(false);\n    }, LEAVE_DELAY);\n  };\n\n  const percent =\n    activity?.progress === null || activity?.progress === undefined\n      ? null\n      : Math.round(Math.min(1, Math.max(0, activity.progress)) * 100);\n\n  return (\n    <div\n      role=\"region\"\n      aria-label={label}\n      className={`pointer-events-none flex justify-center ${className}`}\n    >\n      <AnimatePresence initial={false}>\n        {activity ? (\n          <motion.div\n            key=\"pod\"\n            initial={\n              reduced\n                ? { opacity: 0 }\n                : { opacity: 0, y: -10, scale: 0.9, filter: \"blur(6px)\" }\n            }\n            animate={{\n              opacity: 1,\n              y: 0,\n              scale: 1,\n              filter: \"blur(0px)\",\n              width: dims?.w,\n              height: dims?.h,\n            }}\n            exit={\n              reduced\n                ? { opacity: 0, transition: INSTANT }\n                : {\n                    opacity: 0,\n                    y: -8,\n                    scale: 0.97,\n                    filter: \"blur(3px)\",\n                    transition: { duration: 0.16, ease: LEAVE },\n                  }\n            }\n            transition={\n              reduced\n                ? INSTANT\n                : {\n                    ...SURFACE,\n                    opacity: { duration: 0.2, ease: EASE },\n                    filter: { duration: 0.2, ease: EASE },\n                  }\n            }\n            style={{ transformOrigin: \"50% 0%\" }}\n            onPointerEnter={enter}\n            onPointerLeave={leave}\n            onFocusCapture={() => setFocused(true)}\n            onBlurCapture={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {\n                setFocused(false);\n              }\n            }}\n            onKeyDown={(e) => {\n              if (e.key !== \"Escape\") return;\n              e.preventDefault();\n              if (phase === \"running\") setHovered(false);\n              else onDismiss?.();\n            }}\n            className=\"pointer-events-auto relative overflow-hidden rounded-[11px] border border-stone-200 bg-white shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(28,25,23,0.07),0_16px_36px_-18px_rgba(28,25,23,0.5)] dark:border-white/[0.16] dark:bg-[#252522] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_2px_12px_rgba(0,0,0,0.55)]\"\n          >\n            <motion.div\n              ref={compactRef}\n              aria-hidden={expanded}\n              inert={expanded}\n              initial={false}\n              animate={{ opacity: expanded ? 0 : 1 }}\n              transition={reduced ? INSTANT : CROSSFADE}\n              className={`absolute left-0 top-0 flex h-8 w-max items-center gap-1.5 px-2.5 ${face(!expanded)}`}\n            >\n              <PhaseGlyph phase={phase} reduced={reduced} />\n              {percent !== null && phase === \"running\" ? (\n                <span className=\"font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n                  {percent}%\n                </span>\n              ) : (\n                <span className=\"max-w-[120px] truncate text-[12px] font-medium text-stone-700 dark:text-stone-200\">\n                  {activity.title}\n                </span>\n              )}\n            </motion.div>\n\n            <motion.div\n              ref={expandedRef}\n              aria-hidden={!expanded}\n              inert={!expanded}\n              initial={false}\n              animate={{ opacity: expanded ? 1 : 0 }}\n              transition={reduced ? INSTANT : CROSSFADE}\n              style={{ width }}\n              className={`absolute left-0 top-0 px-3.5 py-3 ${face(expanded)}`}\n            >\n              <div className=\"flex items-center gap-2\">\n                <PhaseGlyph phase={phase} reduced={reduced} />\n                <span className=\"min-w-0 flex-1 truncate text-[13px] font-medium text-stone-700 dark:text-stone-100\">\n                  {activity.title}\n                </span>\n                {activity.action ? (\n                  <button\n                    type=\"button\"\n                    tabIndex={expanded ? 0 : -1}\n                    onClick={activity.action.onClick}\n                    className=\"inline-flex h-[24px] shrink-0 select-none items-center whitespace-nowrap rounded-[6px] border border-stone-200 bg-white px-2 text-[11px] font-medium text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-stone-50 focus-visible:border-[#4568FF] active:translate-y-px dark:border-white/[0.16] dark:bg-[#2A2A27] dark:text-stone-100 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#32322E] dark:focus-visible:border-[#93B0FF]\"\n                  >\n                    {activity.action.label}\n                  </button>\n                ) : null}\n                {onDismiss && phase !== \"running\" ? (\n                  <button\n                    type=\"button\"\n                    tabIndex={expanded ? 0 : -1}\n                    aria-label={dismissLabel}\n                    onClick={onDismiss}\n                    className=\"grid size-[22px] shrink-0 place-items-center rounded-[6px] text-stone-400 transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] focus-visible:outline-none dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n                  >\n                    <svg width=\"10\" height=\"10\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden>\n                      <path\n                        d=\"M2.8 2.8l6.4 6.4M9.2 2.8l-6.4 6.4\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.6\"\n                        strokeLinecap=\"round\"\n                      />\n                    </svg>\n                  </button>\n                ) : null}\n              </div>\n\n              {activity.detail ? (\n                <p className=\"mt-1 truncate pl-[26px] text-[11.5px] text-stone-500 dark:text-stone-400\">\n                  {activity.detail}\n                </p>\n              ) : null}\n\n              {percent !== null && phase !== \"error\" ? (\n                <div className=\"mt-2.5 flex items-center gap-2 pl-[26px]\">\n                  <div className=\"min-w-0 flex-1 rounded-[4px] bg-stone-200/60 p-[2px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.1)] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\">\n                    <div className=\"relative h-[4px] overflow-hidden rounded-[2px]\">\n                      <motion.span\n                        aria-hidden\n                        className=\"absolute inset-0 block origin-left rounded-[2px] bg-[#4568FF] shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] dark:bg-[#93B0FF]\"\n                        initial={false}\n                        animate={{ scaleX: (percent ?? 0) / 100 }}\n                        transition={reduced ? INSTANT : FILL}\n                      />\n                    </div>\n                  </div>\n                  <span className=\"shrink-0 font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n                    {percent}%\n                  </span>\n                </div>\n              ) : null}\n\n            </motion.div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {spoken}\n      </span>\n    </div>\n  );\n}\n\nfunction PhaseGlyph({ phase, reduced }: { phase: ActivityPhase; reduced: boolean }) {\n  return (\n    <span className=\"grid size-[18px] shrink-0 place-items-center\">\n      <motion.span\n        className=\"col-start-1 row-start-1 flex\"\n        initial={false}\n        animate={{\n          opacity: phase === \"running\" ? 1 : 0,\n          scale: reduced ? 1 : phase === \"running\" ? 1 : 0.7,\n        }}\n        transition={reduced ? INSTANT : SMALL}\n      >\n        {reduced ? (\n          <svg width=\"13\" height=\"13\" viewBox=\"0 0 12 12\" aria-hidden>\n            <circle cx=\"6\" cy=\"6\" r=\"4.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" fill=\"none\" className=\"text-stone-400 dark:text-stone-500\" opacity=\"0.4\" />\n          </svg>\n        ) : (\n          <motion.svg\n            width=\"13\"\n            height=\"13\"\n            viewBox=\"0 0 12 12\"\n            aria-hidden\n            style={{ transformOrigin: \"50% 50%\" }}\n            animate={{ rotate: 360 }}\n            transition={SPIN}\n            className=\"text-[#4568FF] dark:text-[#93B0FF]\"\n          >\n            <circle cx=\"6\" cy=\"6\" r=\"4.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" fill=\"none\" opacity=\"0.25\" />\n            <path d=\"M6 1.6a4.4 4.4 0 0 1 4.4 4.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" fill=\"none\" />\n          </motion.svg>\n        )}\n      </motion.span>\n      <motion.span\n        className=\"col-start-1 row-start-1 flex text-emerald-600 dark:text-emerald-400\"\n        initial={false}\n        animate={{\n          opacity: phase === \"success\" ? 1 : 0,\n          scale: reduced ? 1 : phase === \"success\" ? 1 : 0.7,\n        }}\n        transition={reduced ? INSTANT : SMALL}\n      >\n        <svg width=\"13\" height=\"13\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden>\n          <motion.path\n            d=\"M2.4 6.4 4.8 8.8 9.6 3.4\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={false}\n            animate={{ pathLength: phase === \"success\" ? 1 : 0 }}\n            transition={reduced ? INSTANT : DRAW}\n          />\n        </svg>\n      </motion.span>\n      <motion.span\n        className=\"col-start-1 row-start-1 flex text-red-600 dark:text-red-400\"\n        initial={false}\n        animate={{\n          opacity: phase === \"error\" ? 1 : 0,\n          scale: reduced ? 1 : phase === \"error\" ? 1 : 0.7,\n        }}\n        transition={reduced ? INSTANT : SMALL}\n      >\n        <svg width=\"13\" height=\"13\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden>\n          <path d=\"M6 2.6v3.6\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n          <rect x=\"5.2\" y=\"8.2\" width=\"1.6\" height=\"1.6\" rx=\"0.4\" fill=\"currentColor\" />\n        </svg>\n      </motion.span>\n    </span>\n  );\n}\n"}]}