{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-feed",
  "type": "registry:ui",
  "title": "Activity Feed",
  "description": "A timeline of who did what, grouped by day, that holds your place as it updates.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/activity-feed.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp, Bolt, CircleCheck, CircleX, Loader, Message, Pencil, Plus, Tag, Trash, Upload, Users, Warning } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ActivityKind = \"merge\" | \"commit\" | \"comment\" | \"deploy\" | \"approve\" | \"request-changes\" | \"open\" | \"close\" | \"edit\" | \"delete\" | \"invite\" | \"upload\" | \"release\";\n\nexport type ActivityItem = {\n  id: string;\n  actor: { name: string; avatarUrl?: string };\n  /** The verb phrase: \"merged\", \"commented on\", \"deployed\". */\n  action: React.ReactNode;\n  /** What it happened to. Rendered as a link when it has an href. */\n  target?: { label: React.ReactNode; href?: string };\n  /** Anything that follows the target: \"to production\", \"from main\". */\n  suffix?: React.ReactNode;\n  /** A quote, a commit list, a diff summary: shown under the sentence. */\n  detail?: React.ReactNode;\n  at: Date | string | number;\n  /** Picks the icon and its tone. */\n  kind?: ActivityKind;\n  /** Overrides the icon for this item. */\n  icon?: React.ReactNode;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Time: one shared clock, 30s ticks while the tab is visible; 0 on the server and while hydrating\n * -----------------------------------------------------------------------------------------------*/\n\nlet clock = 0;\nlet ticker: number | undefined;\nconst listeners = new Set<() => void>();\nconst tick = () => {\n  if (document.hidden) return;\n  clock = Date.now();\n  listeners.forEach((l) => l());\n};\nfunction subscribeClock(cb: () => void) {\n  listeners.add(cb);\n  if (ticker === undefined) {\n    ticker = window.setInterval(tick, 30_000);\n    document.addEventListener(\"visibilitychange\", tick);\n  }\n  return () => {\n    listeners.delete(cb);\n    if (!listeners.size) {\n      window.clearInterval(ticker);\n      ticker = undefined;\n      document.removeEventListener(\"visibilitychange\", tick);\n    }\n  };\n}\nconst readClock = () => clock || (clock = Date.now());\nconst useNow = () => useSyncExternalStore(subscribeClock, readClock, () => 0);\n\nconst toMs = (d: Date | string | number) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : Date.parse(d));\nconst dayStart = (ms: number) => new Date(ms).setHours(0, 0, 0, 0);\nconst fmtWeekday = new Intl.DateTimeFormat(undefined, { weekday: \"long\" });\nconst fmtDay = new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" });\nconst fmtDayYear = new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" });\nconst fmtTime = new Intl.DateTimeFormat(undefined, { hour: \"numeric\", minute: \"2-digit\" });\nconst fmtFull = new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" });\n\nfunction groupLabel(dayMs: number, now: number) {\n  const days = Math.round((dayStart(now) - dayMs) / 864e5);\n  if (days <= 0) return \"Today\";\n  if (days === 1) return \"Yesterday\";\n  if (days < 7) return fmtWeekday.format(dayMs);\n  return new Date(dayMs).getFullYear() === new Date(now).getFullYear() ? fmtDay.format(dayMs) : fmtDayYear.format(dayMs);\n}\n\n/** Recent times read as a distance (\"4m\"); older ones as the clock time, since the day is in the header. */\nfunction shortTime(ms: number, now: number) {\n  if (!now) return fmtTime.format(ms);\n  const s = Math.max(0, (now - ms) / 1000);\n  if (s < 45) return \"now\";\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m}m`;\n  const h = Math.round(m / 60);\n  if (h < 12 && dayStart(ms) === dayStart(now)) return `${h}h`;\n  return fmtTime.format(ms);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Icons: the shared set plus merge and commit, drawn on the same grid\n * -----------------------------------------------------------------------------------------------*/\n\nconst glyph = {\n  width: 14,\n  height: 14,\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n};\nconst MergeIcon = () => (\n  <svg {...glyph}>\n    <circle cx=\"5\" cy=\"3.75\" r=\"1.5\" />\n    <circle cx=\"5\" cy=\"12.25\" r=\"1.5\" />\n    <circle cx=\"11.25\" cy=\"8.75\" r=\"1.5\" />\n    <path d=\"M5 5.25v5.5M5 5.5c0 2.2 1.6 3.25 4.75 3.25\" />\n  </svg>\n);\nconst CommitIcon = () => (\n  <svg {...glyph}>\n    <circle cx=\"8\" cy=\"8\" r=\"2.25\" />\n    <path d=\"M2.25 8h3.5M10.25 8h3.5\" />\n  </svg>\n);\n\nconst kinds: Record<ActivityKind, { icon: React.ReactNode; tone: \"default\" | \"success\" | \"danger\" | \"warning\" | \"info\" }> = {\n  merge: { icon: <MergeIcon />, tone: \"info\" },\n  commit: { icon: <CommitIcon />, tone: \"default\" },\n  comment: { icon: <Message size={14} />, tone: \"default\" },\n  deploy: { icon: <Bolt size={14} />, tone: \"success\" },\n  approve: { icon: <CircleCheck size={14} />, tone: \"success\" },\n  \"request-changes\": { icon: <Warning size={14} />, tone: \"warning\" },\n  open: { icon: <Plus size={14} />, tone: \"default\" },\n  close: { icon: <CircleX size={14} />, tone: \"danger\" },\n  edit: { icon: <Pencil size={14} />, tone: \"default\" },\n  delete: { icon: <Trash size={14} />, tone: \"danger\" },\n  invite: { icon: <Users size={14} />, tone: \"default\" },\n  upload: { icon: <Upload size={14} />, tone: \"default\" },\n  release: { icon: <Tag size={14} />, tone: \"info\" },\n};\n\nconst tones = {\n  default: \"text-fg-2\",\n  success: \"text-success\",\n  danger: \"text-danger\",\n  warning: \"text-warning\",\n  info: \"text-info\",\n} as const;\n\n/* -------------------------------------------------------------------------------------------------\n * ActivityFeed\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ActivityFeedProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** Newest first. Prepend to show live activity; append after onLoadMore for older. */\n  items: ActivityItem[];\n  /** Load the next page of older items. A promise keeps the button busy; a rejection shows Retry. */\n  onLoadMore?: () => void | Promise<unknown>;\n  /** Whether older items exist. When false the feed ends with endLabel. */\n  hasMore?: boolean;\n  /** First load: shows rows shaped like the feed instead of an empty state. */\n  loading?: boolean;\n  /** Height of the scroll area. Without it the feed grows with its content and the \"new\" pill is not needed. */\n  maxHeight?: number | string;\n  /** Shown where the history begins, e.g. \"Project created\". */\n  endLabel?: React.ReactNode;\n  /** Shown when there's nothing at all. */\n  emptyLabel?: React.ReactNode;\n  /** Accessible name of the feed. */\n  label?: string;\n};\n\nexport function ActivityFeed({\n  items,\n  onLoadMore,\n  hasMore = false,\n  loading = false,\n  maxHeight,\n  endLabel,\n  emptyLabel = \"No activity yet\",\n  label = \"Activity\",\n  className,\n  style,\n  ...rest\n}: ActivityFeedProps) {\n  const reduce = !!useReducedMotion();\n  const now = useNow();\n  const uid = useId();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [away, setAway] = useState(false);\n  const [unseen, setUnseen] = useState(0);\n  const [head, setHead] = useState(items[0]?.id);\n  const [more, setMore] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [live, setLive] = useState<string[]>([]);\n  // The row at the top of the view and how far down it sits, so arrivals above it can't move it.\n  const anchor = useRef<{ id: string; offset: number } | null>(null);\n\n  // New items at the top: count them if the reader has scrolled away, rather than\n  // pushing what they're reading down. (Adjusting state during render, not in an effect.)\n  if (items[0]?.id !== head) {\n    const at = items.findIndex((i) => i.id === head);\n    const added = at > 0 ? at : 0;\n    setHead(items[0]?.id);\n    if (added) {\n      setLive((l) => [...items.slice(0, added).map((i) => i.id), ...l].slice(0, 50));\n      if (away && maxHeight != null) setUnseen((n) => n + added);\n      const first = items[0];\n      setAnnouncement(`${first.actor.name} ${typeof first.action === \"string\" ? first.action : \"\"} ${typeof first.target?.label === \"string\" ? first.target.label : \"\"}`.trim());\n    }\n  }\n\n  const groups = useMemo(() => {\n    const out: { key: string; label: string; items: ActivityItem[] }[] = [];\n    for (const item of items) {\n      const ms = toMs(item.at);\n      const day = dayStart(ms);\n      // Until the client clock is known the headers can't say \"Today\", so the first paint groups by date only.\n      const key = String(day);\n      const last = out[out.length - 1];\n      if (last?.key === key) last.items.push(item);\n      else out.push({ key, label: now ? groupLabel(day, now) : fmtDay.format(day), items: [item] });\n    }\n    return out;\n  }, [items, now]);\n\n  const loadMore = async () => {\n    if (!onLoadMore || more === \"loading\") return;\n    setMore(\"loading\");\n    try {\n      await onLoadMore();\n      setMore(\"idle\");\n    } catch {\n      setMore(\"error\");\n    }\n  };\n\n  const captureAnchor = (el: HTMLElement) => {\n    const top = el.getBoundingClientRect().top;\n    for (const row of el.querySelectorAll<HTMLElement>(\"[data-activity-id]\")) {\n      const r = row.getBoundingClientRect();\n      if (r.bottom > top + 32) {\n        anchor.current = { id: row.dataset.activityId!, offset: r.top - top };\n        return;\n      }\n    }\n  };\n\n  // Keep the reader's place when rows arrive above it: put the anchored row back where it was.\n  useLayoutEffect(() => {\n    const el = scrollRef.current;\n    const a = anchor.current;\n    if (!el || !a || maxHeight == null || el.scrollTop <= 24) return;\n    const row = el.querySelector<HTMLElement>(`[data-activity-id=\"${CSS.escape(a.id)}\"]`);\n    if (!row) return;\n    const d = row.getBoundingClientRect().top - el.getBoundingClientRect().top - a.offset;\n    if (Math.abs(d) > 0.5) el.scrollTop += d;\n  }, [items, maxHeight]);\n\n  const toTop = () => {\n    scrollRef.current?.scrollTo({ top: 0, behavior: reduce ? \"auto\" : \"smooth\" });\n    setUnseen(0);\n  };\n\n  return (\n    <section\n      aria-label={label}\n      aria-busy={loading || more === \"loading\" || undefined}\n      className={cn(\"relative flex min-w-0 flex-col [--feed-bg:var(--raised)]\", className)}\n      style={style}\n      {...rest}\n    >\n      {/* \"3 new\" floats over the top edge; pressing it scrolls up to them. */}\n      <AnimatePresence>\n        {unseen > 0 && (\n          <motion.button\n            type=\"button\"\n            onClick={toTop}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.96 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.12 } : spring.snappy}\n            className={cn(\n              \"absolute left-1/2 top-2 z-10 inline-flex h-7 -translate-x-1/2 items-center gap-1.5 rounded-full bg-fg px-3 text-[12px] font-medium text-frame shadow-pop outline-none\",\n              \"transition-[background-color,scale] duration-150 hover:bg-fg/90 active:scale-[0.96]\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            )}\n          >\n            <ArrowUp size={12} />\n            {unseen} new\n          </motion.button>\n        )}\n      </AnimatePresence>\n\n      <div\n        ref={scrollRef}\n        onScroll={(e) => {\n          const top = e.currentTarget.scrollTop;\n          const isAway = top > 24;\n          if (isAway !== away) setAway(isAway);\n          if (!isAway && unseen) setUnseen(0);\n          captureAnchor(e.currentTarget);\n        }}\n        style={{ maxHeight }}\n        className={cn(\n          \"relative min-w-0\",\n          // The bottom edge fades so a cut-off row reads as \"more below\". Anchoring is done by hand (above), so the browser's is off.\n          maxHeight != null &&\n            \"overflow-y-auto overscroll-contain pb-6 [overflow-anchor:none] [scrollbar-width:thin] [mask-image:linear-gradient(to_bottom,black_calc(100%-24px),transparent)]\",\n        )}\n      >\n        {loading && items.length === 0 ? (\n          <Skeleton />\n        ) : items.length === 0 ? (\n          <div className=\"flex flex-col items-center gap-1 px-4 py-10 text-center\">\n            <p className=\"text-[13px] font-medium text-fg\">{emptyLabel}</p>\n            <p className=\"text-[12.5px] text-fg-3\">Changes, comments and deploys will show up here.</p>\n          </div>\n        ) : (\n          <div className=\"relative\">\n            {groups.map((g) => (\n              <div key={g.key} role=\"group\" aria-labelledby={`${uid}-${g.key}`} className=\"relative\">\n                <h3\n                  id={`${uid}-${g.key}`}\n                  suppressHydrationWarning\n                  className={cn(\n                    \"sticky top-0 z-[1] flex h-8 items-center pl-10 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\",\n                    maxHeight != null && \"bg-[var(--feed-bg)]\",\n                  )}\n                >\n                  {g.label}\n                </h3>\n                <ol className=\"flex flex-col\">\n                  <AnimatePresence initial={false}>\n                    {g.items.map((item, i) => (\n                      <Row\n                        key={item.id}\n                        item={item}\n                        now={now}\n                        reduce={reduce}\n                        live={live.includes(item.id)}\n                        // Rows that arrive while the reader is scrolled away appear at full height, so the anchor holds.\n                        instant={away && maxHeight != null}\n                        first={i === 0}\n                        // The last row of the last day runs on into \"Load older\" or the end marker.\n                        last={i === g.items.length - 1 && (g !== groups[groups.length - 1] || (!(hasMore && onLoadMore) && !endLabel))}\n                      />\n                    ))}\n                  </AnimatePresence>\n                </ol>\n              </div>\n            ))}\n\n            <FeedEnd hasMore={hasMore && !!onLoadMore} state={more} onLoad={() => void loadMore()} endLabel={endLabel} />\n          </div>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {more === \"error\" ? \"Couldn’t load older activity\" : announcement}\n      </span>\n    </section>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Row({\n  item,\n  now,\n  reduce,\n  live,\n  instant,\n  first,\n  last,\n  ref,\n}: {\n  item: ActivityItem;\n  now: number;\n  reduce: boolean;\n  live: boolean;\n  instant: boolean;\n  first: boolean;\n  last: boolean;\n  ref?: React.Ref<HTMLLIElement>;\n}) {\n  const kind = item.kind ? kinds[item.kind] : null;\n  const ms = toMs(item.at);\n  const target = item.target;\n\n  return (\n    <motion.li\n      ref={ref}\n      data-activity-id={item.id}\n      initial={instant ? false : reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={{ opacity: 0, height: 0, transition: { duration: 0.16 } }}\n      transition={reduce ? { duration: 0.15 } : { height: { duration: 0.28, ease: ease.inOut }, opacity: { duration: 0.2, delay: 0.06 } }}\n      className=\"relative overflow-hidden\"\n    >\n      {/* A row that arrives live starts lit and cools to the page, so the eye finds it. */}\n      {live && (\n        <motion.span\n          aria-hidden\n          initial={{ opacity: 1 }}\n          animate={{ opacity: 0 }}\n          transition={{ duration: 1.4, delay: 0.5, ease: ease.outQuart }}\n          className=\"pointer-events-none absolute inset-y-0.5 left-7 right-0 rounded-lg bg-fg/[0.07]\"\n        />\n      )}\n      {/* The timeline is drawn per row, node centre to node centre, so it starts and ends exactly on a node. */}\n      {!first && <span aria-hidden className=\"pointer-events-none absolute left-[13.5px] top-0 h-5 w-px bg-line-2\" />}\n      {!last && <span aria-hidden className=\"pointer-events-none absolute bottom-0 left-[13.5px] top-5 w-px bg-line-2\" />}\n      <div className=\"relative grid grid-cols-[28px_minmax(0,1fr)_auto] items-start gap-x-3 py-1.5\">\n        <motion.span\n          aria-hidden\n          initial={reduce || !live ? false : { scale: 0.4, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          transition={reduce ? { duration: 0 } : { ...spring.pop, delay: 0.12 }}\n          className={cn(\"relative grid size-7 place-items-center rounded-full border border-line-2 bg-[var(--feed-bg)]\", kind ? tones[kind.tone] : \"text-fg-2\")}\n        >\n          {item.icon ?? kind?.icon ?? (\n            <span className=\"text-[10px] font-medium text-fg-2\">\n              {item.actor.name\n                .split(/\\s+/)\n                .slice(0, 2)\n                .map((w) => w[0])\n                .join(\"\")}\n            </span>\n          )}\n        </motion.span>\n\n        <div className=\"min-w-0 pt-[5px]\">\n          <p className=\"text-[13px] leading-[18px] text-fg-2 [overflow-wrap:anywhere]\">\n            <span className=\"font-medium text-fg\">{item.actor.name}</span> {item.action}\n            {target && (\n              <>\n                {\" \"}\n                {target.href ? (\n                  <a\n                    href={target.href}\n                    className={cn(\n                      \"rounded-sm font-medium text-fg underline decoration-fg-4 decoration-1 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                    )}\n                  >\n                    {target.label}\n                  </a>\n                ) : (\n                  <span className=\"font-medium text-fg\">{target.label}</span>\n                )}\n              </>\n            )}\n            {item.suffix && <> {item.suffix}</>}\n          </p>\n          {item.detail && <div className=\"mt-1.5 min-w-0 text-[12.5px] text-fg-2\">{item.detail}</div>}\n        </div>\n\n        <time\n          dateTime={new Date(ms).toISOString()}\n          title={fmtFull.format(ms)}\n          suppressHydrationWarning\n          className=\"whitespace-nowrap pt-[5px] text-[12px] leading-[18px] text-fg-3 tabular\"\n        >\n          {shortTime(ms, now)}\n        </time>\n      </div>\n    </motion.li>\n  );\n}\n\nfunction FeedEnd({ hasMore, state, onLoad, endLabel }: { hasMore: boolean; state: \"idle\" | \"loading\" | \"error\"; onLoad: () => void; endLabel?: React.ReactNode }) {\n  if (!hasMore) {\n    return endLabel ? (\n      <div className=\"relative grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-3 py-2\">\n        <span aria-hidden className=\"pointer-events-none absolute left-[13.5px] top-0 h-1/2 w-px bg-line-2\" />\n        <span aria-hidden className=\"mx-auto size-2 rounded-full border border-line-2 bg-[var(--feed-bg)]\" />\n        <p className=\"text-[12px] text-fg-3\">{endLabel}</p>\n      </div>\n    ) : null;\n  }\n  return (\n    <div className=\"relative grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-3 py-2\">\n      <span aria-hidden className=\"pointer-events-none absolute left-[13.5px] top-0 h-1/2 w-px bg-line-2 [mask-image:linear-gradient(to_bottom,black,transparent)]\" />\n      <span aria-hidden className=\"mx-auto grid size-5 place-items-center rounded-full border border-dashed border-line-2 bg-[var(--feed-bg)] text-fg-3\">\n        {state === \"loading\" ? <Loader size={11} className=\"animate-spin\" /> : <span className=\"size-1 rounded-full bg-fg-4\" />}\n      </span>\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {state === \"error\" && <span className=\"text-[12px] text-danger\">Couldn’t load older activity.</span>}\n        <button\n          type=\"button\"\n          onClick={onLoad}\n          disabled={state === \"loading\"}\n          aria-busy={state === \"loading\" || undefined}\n          className={cn(\n            \"relative -ml-1.5 inline-flex h-7 items-center rounded-md px-1.5 text-[12.5px] font-medium text-fg-2 outline-none\",\n            \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-fg/[0.06] hover:text-fg active:scale-[0.97] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n            \"disabled:cursor-progress disabled:text-fg-3 disabled:hover:bg-transparent\",\n            \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          {state === \"loading\" ? \"Loading older activity…\" : state === \"error\" ? \"Try again\" : \"Load older activity\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction Skeleton() {\n  return (\n    <div aria-hidden className=\"flex flex-col gap-1 py-1\">\n      <span className=\"ml-10 h-8 w-16 py-3\">\n        <span className=\"block h-2 w-full rounded-full bg-fg/[0.06] animate-pulse-soft\" />\n      </span>\n      {[72, 88, 60, 80].map((w, i) => (\n        <div key={i} className=\"grid grid-cols-[28px_minmax(0,1fr)_auto] items-center gap-x-3 py-1.5\">\n          <span className=\"size-7 rounded-full bg-fg/[0.06] animate-pulse-soft\" />\n          <span className=\"h-2.5 rounded-full bg-fg/[0.06] animate-pulse-soft\" style={{ width: `${w}%` }} />\n          <span className=\"h-2.5 w-6 rounded-full bg-fg/[0.06] animate-pulse-soft\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/activity-feed"
}
