{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "changelog",
  "type": "registry:ui",
  "title": "Changelog",
  "description": "Releases on a timeline rail that lights the one you’re reading.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@base-ui/react",
    "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",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/changelog.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { useCopy } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowUp, Check, Hash, Image as ImageIcon, Loader, Sparkle } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ChangeType = \"new\" | \"improved\" | \"fixed\";\n\nexport type ChangelogChange = { type: ChangeType; text: React.ReactNode };\n\nexport type ChangelogRelease = {\n  /** Anchor id. Defaults to the version, slugged: \"v2.14.0\" → \"v2-14-0\". */\n  id?: string;\n  version: string;\n  /** ISO date, \"2026-09-18\". Formatted in UTC so the server and browser agree. */\n  date: string;\n  title: string;\n  summary?: React.ReactNode;\n  /** A screenshot. Width and height reserve its box before it loads. */\n  image?: { src: string; alt: string; width: number; height: number; caption?: React.ReactNode };\n  /** Anything else in the image slot: a video, a drawn mock. Wins over image. */\n  media?: React.ReactNode;\n  changes: ChangelogChange[];\n};\n\nexport type ChangelogFilter = \"all\" | ChangeType;\n\nconst TYPES: { type: ChangeType; label: string; icon: typeof Sparkle }[] = [\n  { type: \"new\", label: \"New\", icon: Sparkle },\n  { type: \"improved\", label: \"Improved\", icon: ArrowUp },\n  { type: \"fixed\", label: \"Fixed\", icon: Check },\n];\n\nconst EMPTY: Record<ChangeType, string> = {\n  new: \"No new features in these releases\",\n  improved: \"No improvements in these releases\",\n  fixed: \"No fixes in these releases\",\n};\n\nexport const releaseId = (r: ChangelogRelease) => r.id ?? r.version.toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\");\n\n/** The nearest ancestor that scrolls, or null for the window. */\nfunction scrollParent(el: HTMLElement | null): HTMLElement | null {\n  for (let p = el?.parentElement; p; p = p.parentElement) {\n    const { overflowY } = getComputedStyle(p);\n    if (/(auto|scroll|overlay)/.test(overflowY) && p.scrollHeight > p.clientHeight) return p;\n  }\n  return null;\n}\n\nexport type ChangelogProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  releases: ChangelogRelease[];\n  /** Which changes show. Controlled… */\n  filter?: ChangelogFilter;\n  /** …or where it starts. */\n  defaultFilter?: ChangelogFilter;\n  onFilterChange?: (filter: ChangelogFilter) => void;\n  /** Hide the New / Improved / Fixed switcher. */\n  hideFilter?: boolean;\n  /** Older releases exist. Shows the load button under the last one. */\n  hasMore?: boolean;\n  /** Fetch and append older releases. Reject to show an error beside the button. */\n  onLoadMore?: () => Promise<void> | void;\n  /** Height of a sticky site header, so dates and anchors land below it. */\n  stickyTop?: number;\n  /** Heading level of each release title; change groups sit one level below. */\n  headingLevel?: 2 | 3 | 4;\n  /** Fixed locale for dates and counts, so the server and the browser print the same thing. */\n  locale?: string;\n  latestLabel?: string;\n};\n\n/**\n * Releases on a timeline rail. The dot of the release you are reading lights\n * up as you scroll, dates stick beside long entries, every title copies a\n * link to itself, and arriving at one by its link washes it once so the eye\n * finds it.\n */\nexport function Changelog({\n  releases,\n  filter: filterProp,\n  defaultFilter = \"all\",\n  onFilterChange,\n  hideFilter = false,\n  hasMore = false,\n  onLoadMore,\n  stickyTop = 0,\n  headingLevel = 2,\n  locale = \"en-US\",\n  latestLabel = \"Latest\",\n  className,\n  style,\n  ...rest\n}: ChangelogProps) {\n  const reduce = useReducedMotion();\n  const [filter, setFilter] = useControllableState({ value: filterProp, defaultValue: defaultFilter, onChange: onFilterChange });\n  const root = useRef<HTMLDivElement>(null);\n  const [active, setActive] = useState<string | null>(null);\n  const [flash, setFlash] = useState<string | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [slow, setSlow] = useState(false);\n  const [loadError, setLoadError] = useState(false);\n  // Releases on screen at first paint never animate; ones loaded later rise in once.\n  const [known, setKnown] = useState(() => new Set(releases.map(releaseId)));\n  const alive = useRef(true);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const visible = releases\n    .map((r) => ({ ...r, changes: filter === \"all\" ? r.changes : r.changes.filter((c) => c.type === filter) }))\n    .filter((r) => filter === \"all\" || r.changes.length > 0);\n\n  const counts = { all: 0, new: 0, improved: 0, fixed: 0 } as Record<ChangelogFilter, number>;\n  for (const r of releases)\n    for (const c of r.changes) {\n      counts[c.type]++;\n      counts.all++;\n    }\n\n  // The lit dot: the last release whose top has passed a line just under the\n  // sticky header. At the very bottom the last release wins, however short.\n  const visibleKey = visible.map(releaseId).join(\",\");\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const sp = scrollParent(el);\n    let frame = 0;\n    const check = () => {\n      frame = 0;\n      const items = Array.from(el.querySelectorAll<HTMLElement>(\"[data-release]\"));\n      if (!items.length) return setActive(null);\n      const top = (sp ? sp.getBoundingClientRect().top : 0) + stickyTop + 48;\n      let id = items[0].id;\n      for (const a of items) if (a.getBoundingClientRect().top <= top) id = a.id;\n      const atEnd = sp ? sp.scrollTop + sp.clientHeight >= sp.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;\n      if (atEnd && (sp ? sp.scrollTop : window.scrollY) > 0) {\n        const bottom = sp ? sp.getBoundingClientRect().bottom : window.innerHeight;\n        const last = items.filter((a) => a.getBoundingClientRect().top < bottom - 40).pop();\n        if (last) id = last.id;\n      }\n      setActive((a) => (a === id ? a : id));\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(check);\n    };\n    (sp ?? window).addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll, { passive: true });\n    onScroll();\n    return () => {\n      (sp ?? window).removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [visibleKey, stickyTop]);\n\n  // Arriving by a release's link washes it once.\n  useEffect(() => {\n    const read = () => {\n      const id = decodeURIComponent(location.hash.slice(1));\n      if (id && root.current?.querySelector(`[data-release][id=\"${CSS.escape(id)}\"]`)) setFlash(id);\n    };\n    const frame = requestAnimationFrame(read);\n    window.addEventListener(\"hashchange\", read);\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"hashchange\", read);\n    };\n  }, []);\n  useEffect(() => {\n    if (!flash) return;\n    const t = window.setTimeout(() => setFlash(null), 1400);\n    return () => window.clearTimeout(t);\n  }, [flash]);\n\n  // The spinner shows only if loading outlasts 150ms.\n  useEffect(() => {\n    if (!loading) return;\n    const t = window.setTimeout(() => setSlow(true), 150);\n    return () => {\n      window.clearTimeout(t);\n      setSlow(false);\n    };\n  }, [loading]);\n\n  const loadMore = async () => {\n    if (loading || !onLoadMore) return;\n    setLoading(true);\n    setLoadError(false);\n    const started = performance.now();\n    try {\n      await onLoadMore();\n    } catch {\n      if (alive.current) setLoadError(true);\n    } finally {\n      const elapsed = performance.now() - started;\n      if (elapsed > 150 && elapsed < 450) await new Promise((r) => setTimeout(r, 450 - elapsed));\n      if (alive.current) setLoading(false);\n    }\n  };\n\n  const dateFmt = new Intl.DateTimeFormat(locale, { year: \"numeric\", month: \"short\", day: \"numeric\", timeZone: \"UTC\" });\n  const numFmt = new Intl.NumberFormat(locale);\n  const latest = releases[0] ? releaseId(releases[0]) : null;\n  let entering = 0;\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"changelog\"\n      data-filter={filter}\n      className={cn(\"@container w-full min-w-0\", className)}\n      style={{ ...style, [\"--cl-top\" as string]: `${stickyTop}px` }}\n      {...rest}\n    >\n      {!hideFilter && <FilterBar value={filter} onChange={setFilter} counts={counts} fmt={numFmt} reduce={!!reduce} />}\n\n      {visible.length === 0 ? (\n        <div className=\"flex flex-col items-start gap-3 rounded-xl border border-dashed border-line-2 px-4 py-6\">\n          <p className=\"text-[13px] text-fg-2\">{filter === \"all\" ? \"No releases yet\" : EMPTY[filter]}</p>\n          {filter !== \"all\" && (\n            <button type=\"button\" onClick={() => setFilter(\"all\")} className={secondaryButton}>\n              Show all changes\n            </button>\n          )}\n        </div>\n      ) : (\n        <ol className=\"flex flex-col\">\n          {visible.map((r, i) => {\n            const id = releaseId(r);\n            const fresh = !known.has(id);\n            const delay = fresh ? Math.min(entering++, 8) * 0.04 : 0;\n            return (\n              <Release\n                key={id}\n                id={id}\n                release={r}\n                first={i === 0}\n                last={i === visible.length - 1 && !hasMore}\n                active={active === id}\n                flashing={flash === id}\n                latest={id === latest ? latestLabel : null}\n                level={headingLevel}\n                date={dateFmt.format(new Date(`${r.date}T00:00:00Z`))}\n                enter={fresh ? { delay, reduce: !!reduce, done: () => setKnown((k) => new Set(k).add(id)) } : null}\n              />\n            );\n          })}\n        </ol>\n      )}\n\n      {hasMore && onLoadMore && (\n        <div\n          className={cn(\n            \"relative flex flex-wrap items-center gap-x-3 gap-y-2 pb-2 pl-6 @min-[36rem]:ml-32 @min-[36rem]:pl-8\",\n            // The rail runs on into the button and fades out, so the timeline reads as unfinished.\n            \"before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-linear-to-b before:from-line before:to-transparent before:content-['']\",\n          )}\n        >\n          <button type=\"button\" onClick={loadMore} aria-disabled={loading || undefined} data-busy={loading || undefined} className={cn(secondaryButton, \"data-busy:pointer-events-none\")}>\n            <span className=\"grid place-items-center\">\n              <span className={cn(\"col-start-1 row-start-1 transition-[opacity,filter] duration-150\", loading && \"opacity-60\", slow && \"opacity-0 blur-[2px] motion-reduce:blur-none\")}>\n                {loadError ? \"Try again\" : \"Load older releases\"}\n              </span>\n              <span aria-hidden className={cn(\"col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out-expo\", slow ? \"scale-100 opacity-100\" : \"scale-75 opacity-0\")}>\n                <Loader size={14} className={cn(slow && \"animate-spin motion-reduce:animate-spin-slow\")} />\n              </span>\n            </span>\n            {loading && <span className=\"sr-only\">Loading older releases</span>}\n          </button>\n          {loadError && (\n            <p role=\"alert\" className=\"flex items-center gap-1.5 text-[12px] text-danger\">\n              <Alert size={14} className=\"shrink-0\" />\n              Couldn’t load older releases.\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst secondaryButton = cn(\n  \"relative inline-flex h-8 shrink-0 select-none items-center justify-center rounded-lg border border-line-2 bg-raised px-3 text-[12.5px] font-medium tracking-[-0.005em] text-fg shadow-[var(--shadow)]\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n  \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n);\n\nfunction Release({\n  id,\n  release: r,\n  first,\n  last,\n  active,\n  flashing,\n  latest,\n  level,\n  date,\n  enter,\n}: {\n  id: string;\n  release: ChangelogRelease;\n  first: boolean;\n  last: boolean;\n  active: boolean;\n  flashing: boolean;\n  latest: string | null;\n  level: 2 | 3 | 4;\n  date: string;\n  enter: { delay: number; reduce: boolean; done: () => void } | null;\n}) {\n  const H = `h${level}` as \"h2\";\n  const G = `h${level + 1}` as \"h3\";\n  const titleId = useId();\n  const groups = TYPES.map((t) => ({ ...t, items: r.changes.filter((c) => c.type === t.type) })).filter((g) => g.items.length);\n\n  const meta = (\n    <>\n      <time dateTime={r.date} className=\"text-[12.5px] text-fg-2 tabular\">\n        {date}\n      </time>\n      <span className=\"inline-flex h-5 items-center rounded-md border border-line-2 bg-raised px-1.5 font-mono text-[11px] text-fg\">{r.version}</span>\n      {latest && <span className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">{latest}</span>}\n    </>\n  );\n\n  return (\n    <motion.li\n      id={id}\n      data-release\n      data-active={active || undefined}\n      data-flash={flashing || undefined}\n      aria-labelledby={titleId}\n      initial={enter ? (enter.reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(3px)\" }) : false}\n      animate={enter ? { opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } } : undefined}\n      transition={{ duration: enter?.reduce ? 0.15 : 0.4, ease: ease.out, delay: enter?.delay ?? 0 }}\n      onAnimationComplete={enter?.done}\n      style={{ scrollMarginTop: \"calc(var(--cl-top) + 16px)\" }}\n      className=\"group/rel relative grid @min-[36rem]:grid-cols-[8rem_minmax(0,1fr)]\"\n    >\n      {/* Wide containers: the date and version stick beside the entry while it scrolls. */}\n      <div className=\"hidden @min-[36rem]:block\">\n        <div className=\"sticky top-[calc(var(--cl-top)+16px)] flex flex-col items-end gap-1.5 pr-6 pt-px text-right\">{meta}</div>\n      </div>\n\n      <div\n        className={cn(\n          \"relative min-w-0 pb-12 pl-6 @min-[36rem]:pl-8\",\n          // The rail: a hairline segment per release, joined into one line; it starts at the first dot and fades out after the last.\n          \"before:absolute before:bottom-0 before:left-0 before:w-px before:bg-line before:content-['']\",\n          first ? \"before:top-2.5\" : \"before:top-0\",\n          last && \"before:bg-transparent before:bg-linear-to-b before:from-line before:to-transparent\",\n        )}\n      >\n        {/* The arrival wash sits behind the content and fades out on its own. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute -top-3 bottom-6 left-3 -right-3 rounded-xl bg-hover transition-opacity ease-out-quart\",\n            flashing ? \"opacity-100 duration-150\" : \"opacity-0 duration-[1200ms]\",\n          )}\n        />\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute left-0 top-[5.5px] size-[9px] -translate-x-1/2 rounded-full border transition-[background-color,border-color,box-shadow] duration-200 ease-out\",\n            active ? \"border-fg bg-fg shadow-[0_0_0_3px_var(--frame)]\" : \"border-fg-4 bg-frame shadow-[0_0_0_3px_var(--frame)]\",\n          )}\n        />\n\n        <div className=\"relative flex min-w-0 flex-col gap-3\">\n          <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 @min-[36rem]:hidden\">{meta}</div>\n\n          <div className=\"flex flex-col gap-1.5\">\n            <H id={titleId} className=\"flex min-w-0 items-start gap-1.5 text-[15px] font-medium leading-5 tracking-[-0.015em] text-fg text-balance\">\n              <span className=\"min-w-0\">{r.title}</span>\n              <AnchorLink id={id} version={r.version} />\n            </H>\n            {r.summary != null && <div className=\"max-w-[62ch] text-pretty text-[13px] leading-[1.55] text-fg-2\">{r.summary}</div>}\n          </div>\n\n          {r.media ?? (r.image && <Shot image={r.image} />)}\n\n          {groups.map((g) => (\n            <section key={g.type} className=\"flex flex-col gap-1.5\" aria-label={`${g.label} in ${r.version}`}>\n              <G className=\"flex items-center gap-1.5 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n                <g.icon size={12} className=\"shrink-0\" />\n                {g.label}\n              </G>\n              <ul className=\"flex max-w-[62ch] flex-col gap-1\">\n                {g.items.map((c, i) => (\n                  <li key={i} className=\"relative pl-4 text-pretty text-[13px] leading-[1.55] text-fg-2 [&_code]:rounded-[4px] [&_code]:bg-hover [&_code]:px-1 [&_code]:font-mono [&_code]:text-[12px] [&_code]:text-fg [&_strong]:font-medium [&_strong]:text-fg\">\n                    <span aria-hidden className=\"absolute left-0.5 top-[9px] h-px w-2 bg-fg-4\" />\n                    {c.text}\n                  </li>\n                ))}\n              </ul>\n            </section>\n          ))}\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\n/** Copies the link to a release and puts its hash in the address bar, without scrolling. */\nfunction AnchorLink({ id, version }: { id: string; version: string }) {\n  const { state, copy } = useCopy({ timeout: 1600 });\n  const reduce = useReducedMotion();\n  return (\n    <>\n      <a\n        href={`#${id}`}\n        aria-label={`Copy link to ${version}`}\n        data-state={state}\n        onClick={(e) => {\n          if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n          e.preventDefault();\n          history.replaceState(history.state, \"\", `#${id}`);\n          copy(() => location.href);\n        }}\n        className={cn(\n          \"relative mt-px grid size-[18px] shrink-0 cursor-pointer place-items-center rounded-[5px] text-fg-4 outline-none\",\n          \"transition-[opacity,color,background-color,scale] duration-150 hover:bg-hover hover:text-fg-2 active:scale-[0.9]\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          // Quiet until the release is hovered; always there on touch and while it confirms.\n          \"pointer-fine:opacity-0 pointer-fine:group-hover/rel:opacity-100 focus-visible:opacity-100 data-[state=copied]:opacity-100 data-[state=failed]:opacity-100\",\n          \"data-[state=copied]:text-fg data-[state=failed]:text-danger\",\n          \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={state}\n            className=\"grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n          >\n            {state === \"copied\" ? <Check size={14} /> : state === \"failed\" ? <Alert size={14} /> : <Hash size={14} />}\n          </motion.span>\n        </AnimatePresence>\n      </a>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"copied\" ? `Link to ${version} copied` : state === \"failed\" ? \"Couldn’t copy the link\" : \"\"}\n      </span>\n    </>\n  );\n}\n\n/** A screenshot in a box sized before it loads; fades in when decoded, and a quiet placeholder if it fails. */\nfunction Shot({ image }: { image: NonNullable<ChangelogRelease[\"image\"]> }) {\n  const img = useRef<HTMLImageElement>(null);\n  const [status, setStatus] = useState<\"loading\" | \"loaded\" | \"failed\">(\"loading\");\n  // The image may finish before hydration, when onLoad can't fire; check once mounted.\n  useEffect(() => {\n    const frame = requestAnimationFrame(() => {\n      const el = img.current;\n      if (el?.complete) setStatus(el.naturalWidth ? \"loaded\" : \"failed\");\n    });\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  return (\n    <figure className=\"flex max-w-[640px] flex-col gap-2\">\n      <div\n        className=\"relative overflow-hidden rounded-lg border border-line bg-hover\"\n        style={{ aspectRatio: `${image.width} / ${image.height}` }}\n      >\n        {status !== \"failed\" && (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            ref={img}\n            src={image.src}\n            alt={image.alt}\n            width={image.width}\n            height={image.height}\n            loading=\"lazy\"\n            decoding=\"async\"\n            onLoad={() => setStatus(\"loaded\")}\n            onError={() => setStatus(\"failed\")}\n            className={cn(\"absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out\", status === \"loaded\" ? \"opacity-100\" : \"opacity-0\")}\n          />\n        )}\n        {status === \"failed\" && (\n          <div role=\"img\" aria-label={image.alt} className=\"absolute inset-0 grid place-items-center\">\n            <span className=\"flex items-center gap-1.5 text-[12px] text-fg-3\">\n              <ImageIcon size={14} />\n              Image unavailable\n            </span>\n          </div>\n        )}\n      </div>\n      {image.caption != null && <figcaption className=\"text-pretty text-[12px] text-fg-3\">{image.caption}</figcaption>}\n    </figure>\n  );\n}\n\nfunction FilterBar({\n  value,\n  onChange,\n  counts,\n  fmt,\n  reduce,\n}: {\n  value: ChangelogFilter;\n  onChange: (v: ChangelogFilter) => void;\n  counts: Record<ChangelogFilter, number>;\n  fmt: Intl.NumberFormat;\n  reduce: boolean;\n}) {\n  const id = useId();\n  const options: { value: ChangelogFilter; label: string }[] = [{ value: \"all\", label: \"All\" }, ...TYPES.map((t) => ({ value: t.type, label: t.label }))];\n  return (\n    <ToggleGroup\n      value={[value]}\n      // One filter is always on: pressing the current one again keeps it.\n      onValueChange={(v) => v[0] && onChange(v[0] as ChangelogFilter)}\n      aria-label=\"Show changes\"\n      className=\"mb-8 flex w-fit max-w-full overflow-x-auto rounded-lg border border-line bg-raised p-0.5 [scrollbar-width:none] @min-[36rem]:ml-40\"\n    >\n      {options.map((o) => (\n        <Toggle\n          key={o.value}\n          value={o.value}\n          className={cn(\n            \"relative isolate flex h-7 shrink-0 select-none items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium text-fg-3\",\n            \"outline-none transition-[color,scale] duration-150 hover:text-fg-2 active:scale-[0.97] data-pressed:text-fg\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          )}\n        >\n          {o.value === value && (\n            <motion.span\n              layoutId={`${id}-pill`}\n              className=\"absolute inset-0 -z-10 rounded-md border border-line-2 bg-frame\"\n              transition={reduce ? { duration: 0 } : spring.snappy}\n            />\n          )}\n          {o.label}\n          <span className=\"text-[11.5px] font-normal text-fg-4 tabular\">{fmt.format(counts[o.value])}</span>\n        </Toggle>\n      ))}\n    </ToggleGroup>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/changelog"
}
