{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "changelog-feed",
  "type": "registry:block",
  "title": "Changelog feed",
  "description": "Release notes you can filter, open in place, and scroll through month by month.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/copy-button.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/changelog-feed/changelog-feed.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type FocusEvent,\n  type FormEvent,\n  type KeyboardEvent,\n} from \"react\";\nimport Image, { type StaticImageData } from \"next/image\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion, type Transition, type Variants } from \"motion/react\";\nimport { ArrowRight, Bell, Check, ChevronDown } from \"lucide-react\";\nimport { CopyButton } from \"../../copy-button/copy-button\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\n\nexport type ChangelogKind = \"new\" | \"improved\" | \"fixed\";\ntype Kind = ChangelogKind;\nexport type ChangelogMedia =\n  | {\n      type: \"photo\";\n      src: StaticImageData | string;\n      alt: string;\n      caption: string;\n      /** Pixel size, needed when `src` is a URL rather than an imported image. */ width?: number;\n      height?: number;\n    }\n  | { type: \"code\"; file: string; code: string };\ntype Media = ChangelogMedia;\nexport type ChangelogEntry = {\n  id: string;\n  month: string;\n  date: string;\n  iso: string;\n  version: string;\n  kind: Kind;\n  title: string;\n  summary: string;\n  details: string[];\n  media?: Media;\n};\nexport type ChangelogMonth = { key: string; label: string; short: string };\ntype Entry = ChangelogEntry;\ntype Month = ChangelogMonth;\n\nconst kinds: { id: Kind; label: string }[] = [\n  { id: \"new\", label: \"New\" },\n  { id: \"improved\", label: \"Improved\" },\n  { id: \"fixed\", label: \"Fixed\" },\n];\n\n/** Sample pictures are one flat inline SVG of panels in muted blue-grey, so the block installs without image files. */\nconst art = {\n  src: \"data:image/svg+xml,%3Csvg class='iris-placeholder' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 750'%3E%3Crect width='1200' height='750' fill='%23e3e8f0'/%3E%3Crect x='80' y='80' width='300' height='590' rx='18' fill='%23cdd5e1'/%3E%3Crect x='420' y='80' width='700' height='270' rx='18' fill='%23d6dde8'/%3E%3Crect x='420' y='390' width='330' height='280' rx='18' fill='%23bcc7d6'/%3E%3Crect x='790' y='390' width='330' height='280' rx='18' fill='%23c6cfdc'/%3E%3C/svg%3E\",\n  width: 1200,\n  height: 750,\n};\n\nconst exampleMonths: Month[] = [\n  { key: \"2026-09\", label: \"September 2026\", short: \"Sep\" },\n  { key: \"2026-08\", label: \"August 2026\", short: \"Aug\" },\n  { key: \"2026-07\", label: \"July 2026\", short: \"Jul\" },\n  { key: \"2026-06\", label: \"June 2026\", short: \"Jun\" },\n];\n\nconst envSnippet = `# Pooled, for the app\nDATABASE_URL=\"postgres://app:****@shop.capydb.example:6432/shop?sslmode=verify-full\"\n\n# Direct, for migrations\nDIRECT_URL=\"postgres://app:****@shop.capydb.example:5432/shop?sslmode=verify-full\"`;\n\nconst indexSnippet = `create index concurrently orders_customer_id_idx\n  on orders (customer_id);`;\n\nconst exampleEntries: Entry[] = [\n  {\n    id: \"postgres-18\",\n    month: \"2026-09\",\n    date: \"18 Sep\",\n    iso: \"2026-09-18\",\n    version: \"1.4.0\",\n    kind: \"new\",\n    title: \"Postgres 18, first class\",\n    summary: \"New projects can run Postgres 18 next to 16 and 17, which stays the default.\",\n    details: [\n      \"Pick 16, 17 or 18 when you create a project.\",\n      \"Postgres 19 is available in beta.\",\n      \"A preview database runs the same version as its project.\",\n    ],\n    media: {\n      type: \"photo\",\n      ...art,\n      alt: \"Project settings with a Postgres version picker\",\n      caption: \"Choosing the Postgres version for a new project\",\n    },\n  },\n  {\n    id: \"pause-when-idle\",\n    month: \"2026-09\",\n    date: \"18 Sep\",\n    iso: \"2026-09-18\",\n    version: \"1.4.0\",\n    kind: \"improved\",\n    title: \"Pause when idle, and confirmation before destructive work\",\n    summary: \"A project pauses after 5 min without connections, and delete and restore ask before they start.\",\n    details: [\n      \"A paused project wakes on the next connection, in 308 ms at p50.\",\n      \"Delete and restore show what they will change and wait for your confirmation.\",\n    ],\n  },\n  {\n    id: \"restore-labels\",\n    month: \"2026-09\",\n    date: \"9 Sep\",\n    iso: \"2026-09-09\",\n    version: \"1.3.2\",\n    kind: \"fixed\",\n    title: \"Restore points keep their label\",\n    summary: \"A restore point taken before a migration no longer loses its name once it is verified.\",\n    details: [\n      \"Labels such as Restore point before migration 0042 now stay as written.\",\n      \"Affected restore points were renamed back on 9 Sep.\",\n    ],\n  },\n  {\n    id: \"rotate-credentials\",\n    month: \"2026-09\",\n    date: \"2 Sep\",\n    iso: \"2026-09-02\",\n    version: \"1.3.0\",\n    kind: \"new\",\n    title: \"Rotate credentials from the project page\",\n    summary: \"Issue a new password for a project and get fresh direct and pooled connection strings.\",\n    details: [\n      \"The project shows Rotating credentials until the new password is live.\",\n      \"Both connection strings verify the server certificate.\",\n    ],\n    media: { type: \"code\", file: \".env\", code: envSnippet },\n  },\n  {\n    id: \"verified-tls\",\n    month: \"2026-08\",\n    date: \"26 Aug\",\n    iso: \"2026-08-26\",\n    version: \"1.2.0\",\n    kind: \"new\",\n    title: \"Verified TLS, warm previews, and published benchmarks\",\n    summary: \"Connections verify the server certificate, previews start warm, and our benchmark numbers are public.\",\n    details: [\"Wake time is 308 ms at p50 and 358 ms at worst.\", \"2,301 transactions per second at 32 clients.\"],\n    media: {\n      type: \"photo\",\n      ...art,\n      alt: \"Benchmark page with wake time and throughput panels\",\n      caption: \"The published benchmarks, measured in EU North\",\n    },\n  },\n  {\n    id: \"warm-previews\",\n    month: \"2026-08\",\n    date: \"26 Aug\",\n    iso: \"2026-08-26\",\n    version: \"1.2.0\",\n    kind: \"improved\",\n    title: \"Previews are ready in seconds\",\n    summary: \"A 256 MB preview database is ready in 10.2 s, a 1 GB one in 15.6 s.\",\n    details: [\n      \"Pick Empty or Clone when you create a preview.\",\n      \"Each preview shows when it expires, such as Ready, expires in 18 h.\",\n    ],\n  },\n  {\n    id: \"resume-first-query\",\n    month: \"2026-08\",\n    date: \"14 Aug\",\n    iso: \"2026-08-14\",\n    version: \"1.1.3\",\n    kind: \"fixed\",\n    title: \"Paused projects resume on the first query\",\n    summary: \"A query to a paused project no longer failed while its cell was resuming.\",\n    details: [\n      \"The project shows Resuming until it accepts connections.\",\n      \"The first query waits for the wake instead of failing.\",\n    ],\n  },\n  {\n    id: \"verified-backups\",\n    month: \"2026-08\",\n    date: \"5 Aug\",\n    iso: \"2026-08-05\",\n    version: \"1.1.0\",\n    kind: \"improved\",\n    title: \"Every backup shows its verification\",\n    summary: \"The backups list says whether each nightly backup was verified.\",\n    details: [\n      \"Backups read Verified, Verification pending, Verification failed or Not verified.\",\n      \"For example: Nightly, 02:00, 3.1 GiB, Completed, Verified.\",\n    ],\n  },\n  {\n    id: \"ephemeral\",\n    month: \"2026-07\",\n    date: \"22 Jul\",\n    iso: \"2026-07-22\",\n    version: \"1.0.0\",\n    kind: \"new\",\n    title: \"Ephemeral databases for coding agents\",\n    summary: \"An agent can create a database after one approval. Claim it within 72 h to keep it.\",\n    details: [\"Ephemeral databases run Postgres 18.\", \"An unclaimed database is deleted after 72 h.\"],\n    media: {\n      type: \"photo\",\n      ...art,\n      alt: \"An ephemeral database card with a claim button\",\n      caption: \"ephemeral-7f3a91c2, with 72 h left to claim\",\n    },\n  },\n  {\n    id: \"preview-storage\",\n    month: \"2026-07\",\n    date: \"10 Jul\",\n    iso: \"2026-07-10\",\n    version: \"0.9.1\",\n    kind: \"fixed\",\n    title: \"Previews no longer count against project storage\",\n    summary: \"A clone preview was added to its project's storage meter. Now it has its own.\",\n    details: [\n      \"shop shows 4.2 GiB of 10 GiB, and pr-142 shows its 1.0 GiB separately.\",\n      \"Projects that showed as full were corrected on 10 Jul.\",\n    ],\n  },\n  {\n    id: \"index-advisor\",\n    month: \"2026-07\",\n    date: \"3 Jul\",\n    iso: \"2026-07-03\",\n    version: \"0.9.0\",\n    kind: \"improved\",\n    title: \"Index advisor\",\n    summary: \"The project page suggests indexes your queries are missing.\",\n    details: [\"Each suggestion comes with the statement to run.\", \"Run it yourself, or copy it into a migration.\"],\n    media: { type: \"code\", file: \"Suggested index\", code: indexSnippet },\n  },\n  {\n    id: \"one-postgres-per-cell\",\n    month: \"2026-06\",\n    date: \"24 Jun\",\n    iso: \"2026-06-24\",\n    version: \"0.8.0\",\n    kind: \"new\",\n    title: \"One Postgres per cell\",\n    summary: \"Every project now runs in its own isolated database cell.\",\n    details: [\n      \"Each cell has its own direct and pooled connection strings.\",\n      \"Backups and restore points belong to one project.\",\n    ],\n    media: {\n      type: \"photo\",\n      ...art,\n      alt: \"A grid of database cells with one highlighted\",\n      caption: \"Each project in its own cell on the grid\",\n    },\n  },\n  {\n    id: \"import-encoding\",\n    month: \"2026-06\",\n    date: \"12 Jun\",\n    iso: \"2026-06-12\",\n    version: \"0.7.2\",\n    kind: \"fixed\",\n    title: \"Imports keep accented names\",\n    summary: \"Importing a dump no longer replaced letters such as é and ø in text columns.\",\n    details: [\"The project shows Importing until every table is loaded.\", \"Re-run any import made since 0.7.0.\"],\n  },\n  {\n    id: \"eight-languages\",\n    month: \"2026-06\",\n    date: \"3 Jun\",\n    iso: \"2026-06-03\",\n    version: \"0.7.0\",\n    kind: \"improved\",\n    title: \"The dashboard in eight languages\",\n    summary: \"Pick one of 8 languages for the dashboard. English stays the default.\",\n    details: [\"Change the language from your account menu.\", \"New accounts start in English.\"],\n    media: {\n      type: \"photo\",\n      ...art,\n      alt: \"The account menu with a language list\",\n      caption: \"Choosing a language in the account menu\",\n    },\n  },\n];\n\n/* Layout steps follow the viewport: below 640 px the header stacks and each row puts its date above the text; below 480 px the month bar drops its counts. */\nconst mono = \"font-[ui-monospace,SFMono-Regular,Menlo,monospace] tracking-[0]\";\n/** Each kind sets the colour of its dot (`--kind`), of its filled filter mark (`--mark`) and of the tick on that mark (`--tick`). */\nconst kindColors: Record<Kind, string> = {\n  new: \"[--kind:var(--accent)] [--mark:var(--accent)] [--tick:var(--on-accent)]\",\n  improved: \"[--kind:var(--ink-3)] [--mark:var(--page)] [--tick:var(--ink)]\",\n  fixed: \"[--kind:var(--green)] [--mark:var(--green)] [--tick:var(--page)]\",\n};\n\nconst blurSoft = `blur(${motionTokens.blur.soft}px)`;\nconst blurSubtle = `blur(${motionTokens.blur.subtle}px)`;\nconst none = \"blur(0px)\";\nconst instant: Transition = { duration: 0 };\nconst exitSpring: Transition = { type: \"spring\", visualDuration: 0.28, bounce: 0 };\n\n/** Values rise when they grow and fall when they shrink; the outgoing value leaves the opposite way. */\nconst roll: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 55}%`, filter: blurSubtle }),\n  center: { opacity: 1, y: \"0%\", filter: none },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -55}%`, filter: blurSubtle }),\n};\nconst rise: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: direction * 14, filter: blurSoft }),\n  center: { opacity: 1, y: 0, filter: none },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: direction * -14,\n    filter: blurSoft,\n    transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] },\n  }),\n};\n\nfunction RollingNumber({ value, reduce }: { value: number; reduce: boolean }) {\n  const [state, setState] = useState({ value, direction: 1 });\n  if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });\n  return (\n    <span className=\"relative inline-flex overflow-hidden tabular-nums [&>span]:inline-block\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={state.direction}>\n        <motion.span\n          key={value}\n          custom={state.direction}\n          variants={roll}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={reduce ? instant : motionTokens.spring.snappy}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst emailPattern = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\ntype SubscribeState = \"idle\" | \"editing\" | \"done\";\n\n/** One pill that becomes a field, then a confirmation. The shell morphs its width; the content swaps a beat later inside it. */\nfunction SubscribeControl({ reduce }: { reduce: boolean }) {\n  const inputId = useId();\n  const [state, setState] = useState<SubscribeState>(\"idle\");\n  const [email, setEmail] = useState(\"\");\n  const [error, setError] = useState(false);\n  const [width, setWidth] = useState<number | null>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const idleRef = useRef<HTMLButtonElement>(null);\n  const undoRef = useRef<HTMLButtonElement>(null);\n  const focusNext = useRef(false);\n  const [shakeScope, animate] = useAnimate<HTMLDivElement>();\n\n  useEffect(() => {\n    const element = innerRef.current;\n    if (!element) return;\n    const observer = new ResizeObserver(() => setWidth(element.offsetWidth));\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (!focusNext.current) return;\n    focusNext.current = false;\n    const target = state === \"editing\" ? inputRef.current : state === \"done\" ? undoRef.current : idleRef.current;\n    target?.focus({ preventScroll: true });\n  }, [state]);\n\n  function go(next: SubscribeState, focus = true) {\n    focusNext.current = focus;\n    setState(next);\n  }\n  function submit(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    if (!emailPattern.test(email.trim())) {\n      setError(true);\n      if (!reduce && shakeScope.current)\n        animate(\n          shakeScope.current,\n          { x: [0, -6, 5, -3, 2, 0] },\n          { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.standard] },\n        );\n      inputRef.current?.focus();\n      return;\n    }\n    setError(false);\n    go(\"done\");\n  }\n  function onKeyDown(event: KeyboardEvent<HTMLFormElement>) {\n    if (event.key === \"Escape\") {\n      setError(false);\n      go(\"idle\");\n    }\n  }\n  function onBlur(event: FocusEvent<HTMLFormElement>) {\n    if (!event.currentTarget.contains(event.relatedTarget) && !email.trim()) {\n      setError(false);\n      go(\"idle\", false);\n    }\n  }\n  function undo() {\n    setEmail(\"\");\n    go(\"idle\");\n  }\n\n  const swap: Transition = reduce\n    ? { duration: motionTokens.duration.instant }\n    : {\n        opacity: { duration: motionTokens.duration.fast, delay: 0.05 },\n        filter: { duration: motionTokens.duration.fast, delay: 0.05 },\n        scale: { ...motionTokens.spring.morph, delay: 0.03 },\n      };\n  const leave = {\n    opacity: 0,\n    scale: 0.96,\n    filter: blurSubtle,\n    transition: { duration: motionTokens.duration.instant },\n  };\n  const note = error ? \"Enter a valid email address\" : state === \"done\" ? \"Subscribed.\" : \"\";\n\n  return (\n    <div className=\"relative flex-none pt-0.5\">\n      <div ref={shakeScope}>\n        <motion.div\n          className={cn(\n            \"h-[38px] overflow-hidden rounded-full border [transition:background-color_var(--duration-standard)_var(--ease-standard),border-color_var(--duration-standard)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard)]\",\n            state === \"idle\"\n              ? \"border-ink bg-ink text-page\"\n              : state === \"editing\"\n                ? \"border-line-strong bg-surface text-ink\"\n                : \"border-line bg-hover text-ink\",\n            error && \"border-red\",\n          )}\n          data-state={state}\n          data-error={error || undefined}\n          initial={false}\n          animate={{ width: width ?? \"auto\" }}\n          transition={reduce ? instant : motionTokens.spring.morph}\n        >\n          <div ref={innerRef} className=\"relative flex h-full w-max\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {state === \"idle\" && (\n                <motion.button\n                  key=\"idle\"\n                  ref={idleRef}\n                  type=\"button\"\n                  className=\"inline-flex h-full cursor-pointer items-center gap-[7px] pr-[17px] pl-[15px] font-body text-sm/[1] font-medium whitespace-nowrap outline-none\"\n                  onClick={() => go(\"editing\")}\n                  initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }}\n                  animate={{ opacity: 1, scale: 1, filter: none }}\n                  exit={leave}\n                  transition={swap}\n                >\n                  <Bell size={14} aria-hidden=\"true\" />\n                  Subscribe\n                </motion.button>\n              )}\n              {state === \"editing\" && (\n                <motion.form\n                  key=\"form\"\n                  className=\"flex h-full items-center gap-1.5 pr-1 pl-[15px]\"\n                  noValidate\n                  onSubmit={submit}\n                  onKeyDown={onKeyDown}\n                  onBlur={onBlur}\n                  initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }}\n                  animate={{ opacity: 1, scale: 1, filter: none }}\n                  exit={leave}\n                  transition={swap}\n                >\n                  <label className=\"sr-only\" htmlFor={inputId}>\n                    Email address\n                  </label>\n                  <input\n                    ref={inputRef}\n                    id={inputId}\n                    className=\"h-full w-[188px] min-w-0 p-0 font-body text-sm/[1] text-ink outline-none placeholder:text-ink-3 max-[640px]:w-[172px] max-[640px]:text-base/[1]\"\n                    type=\"email\"\n                    inputMode=\"email\"\n                    autoComplete=\"email\"\n                    placeholder=\"you@example.com\"\n                    value={email}\n                    aria-invalid={error || undefined}\n                    onChange={(event) => {\n                      setEmail(event.target.value);\n                      if (error) setError(false);\n                    }}\n                  />\n                  <button\n                    type=\"submit\"\n                    className=\"grid size-7 flex-none cursor-pointer place-items-center rounded-button bg-ink p-0 text-page outline-none transition-transform duration-instant ease-standard motion-safe:active:[transform:scale(.94)]\"\n                    aria-label=\"Subscribe to release notes\"\n                  >\n                    <ArrowRight size={14} />\n                  </button>\n                </motion.form>\n              )}\n              {state === \"done\" && (\n                <motion.div\n                  key=\"done\"\n                  className=\"inline-flex h-full items-center gap-[7px] pr-[5px] pl-[13px] text-sm/[inherit] font-medium whitespace-nowrap\"\n                  initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }}\n                  animate={{ opacity: 1, scale: 1, filter: none }}\n                  exit={leave}\n                  transition={swap}\n                >\n                  <svg\n                    className=\"flex-none text-green\"\n                    width={15}\n                    height={15}\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2.2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    aria-hidden=\"true\"\n                  >\n                    <motion.path\n                      d=\"M4 12.5l5 5L20 6.5\"\n                      initial={{ pathLength: 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={\n                        reduce ? instant : { duration: 0.42, ease: [...motionTokens.ease.standard], delay: 0.12 }\n                      }\n                    />\n                  </svg>\n                  <span>Subscribed</span>\n                  <button\n                    ref={undoRef}\n                    type=\"button\"\n                    className=\"ml-1 h-[26px] cursor-pointer rounded-button px-2.5 font-body text-xs/[1] font-medium text-ink-2 outline-none [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-surface hover:text-ink\"\n                    onClick={undo}\n                    aria-label={`Undo subscription for ${email.trim()}`}\n                  >\n                    Undo\n                  </button>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        </motion.div>\n      </div>\n      <output\n        className={cn(\n          \"absolute top-[calc(100%+7px)] right-1.5 m-0 text-xs/[16px] whitespace-nowrap text-ink-3 max-[640px]:right-auto max-[640px]:left-1.5 [&_span]:inline-block\",\n          error && \"text-red\",\n        )}\n        data-error={error || undefined}\n        aria-live=\"polite\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {note && (\n            <motion.span\n              key={note}\n              initial={{ opacity: 0, y: -4, filter: blurSubtle }}\n              animate={{ opacity: 1, y: 0, filter: none }}\n              exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}\n              transition={\n                reduce ? instant : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {note}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </output>\n    </div>\n  );\n}\n\nconst photoImage = \"block size-full object-cover\";\n\nfunction EntryMedia({ media }: { media: Media }) {\n  if (media.type === \"photo\") {\n    return (\n      <figure className=\"mt-[18px] max-w-[560px]\">\n        <div className=\"relative aspect-[16/10] overflow-hidden rounded-[16px] bg-hover after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ink)_8%,transparent)] after:content-['']\">\n          {typeof media.src === \"string\" ? (\n            <Image\n              className={photoImage}\n              src={media.src}\n              alt={media.alt}\n              width={media.width ?? 1600}\n              height={media.height ?? 1067}\n              sizes=\"(max-width: 700px) 100vw, 560px\"\n            />\n          ) : (\n            <Image\n              className={photoImage}\n              src={media.src}\n              alt={media.alt}\n              placeholder=\"blur\"\n              sizes=\"(max-width: 700px) 100vw, 560px\"\n            />\n          )}\n        </div>\n        <figcaption className=\"mt-2 text-xs leading-body text-ink-3\">{media.caption}</figcaption>\n      </figure>\n    );\n  }\n  return (\n    <div className=\"mt-[18px] max-w-[560px] overflow-hidden rounded-[16px] border border-line bg-hover\">\n      <div className=\"flex h-10 items-center justify-between gap-3 border-b border-line pr-1.5 pl-3.5 text-xs/[inherit] text-ink-3\">\n        <span>{media.file}</span>\n        <CopyButton value={media.code} label={`Copy ${media.file}`} iconOnly variant=\"plain\" />\n      </div>\n      <pre\n        className={cn(\n          mono,\n          \"m-0 overflow-x-auto px-4 pt-3.5 pb-4 text-xs/[1.7] text-ink [tab-size:2] [scrollbar-width:thin]\",\n        )}\n      >\n        <code>{media.code}</code>\n      </pre>\n    </div>\n  );\n}\n\nfunction EntryRow({\n  entry,\n  open,\n  onToggle,\n  reduce,\n}: {\n  entry: Entry;\n  open: boolean;\n  onToggle: () => void;\n  reduce: boolean;\n}) {\n  const panelId = useId();\n  const kind = kinds.find((item) => item.id === entry.kind)!;\n  const layout: Transition = reduce ? instant : motionTokens.spring.smooth;\n  return (\n    <motion.li\n      layout=\"position\"\n      className=\"relative border-b border-line-soft bg-surface last:border-b-0\"\n      data-open={open || undefined}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, filter: blurSubtle }}\n      animate={{ opacity: 1, filter: none }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: motionTokens.duration.instant } }\n          : {\n              opacity: 0,\n              scale: 0.98,\n              filter: blurSubtle,\n              transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] },\n            }\n      }\n      transition={\n        reduce\n          ? { duration: 0, opacity: { duration: motionTokens.duration.instant } }\n          : {\n              opacity: { duration: motionTokens.duration.standard },\n              filter: { duration: motionTokens.duration.standard },\n              layout,\n            }\n      }\n    >\n      <button\n        type=\"button\"\n        className=\"group/row grid w-full cursor-pointer grid-cols-[104px_minmax(0,1fr)_20px] items-start gap-x-6 gap-y-1 px-7 py-[22px] text-left transition-[background-color] duration-standard ease-standard hover:bg-[color-mix(in_srgb,var(--hover)_60%,transparent)] max-[640px]:grid-cols-[minmax(0,1fr)_20px] max-[640px]:gap-x-3 max-[640px]:gap-y-2.5 max-[640px]:px-5 max-[640px]:py-[18px]\"\n        aria-expanded={open}\n        aria-controls={panelId}\n        onClick={onToggle}\n      >\n        <span className=\"grid justify-items-start gap-2 pt-px max-[640px]:col-start-1 max-[640px]:row-start-1 max-[640px]:flex max-[640px]:items-center max-[640px]:gap-2.5 max-[640px]:pt-0\">\n          <time className=\"text-sm/[20px] text-ink-2 tabular-nums\" dateTime={entry.iso}>\n            {entry.date}\n          </time>\n          <span\n            className={cn(\n              mono,\n              \"rounded-[7px] border border-line px-[7px] py-px text-xs/[18px] font-medium whitespace-nowrap text-ink-2 tabular-nums\",\n            )}\n          >\n            v{entry.version}\n          </span>\n        </span>\n        <span className=\"grid min-w-0 gap-1.5 max-[640px]:col-span-2 max-[640px]:row-start-2\">\n          <span\n            className={cn(\n              \"inline-flex items-center gap-[7px] text-xs/[20px] font-medium text-ink-2\",\n              kindColors[entry.kind],\n            )}\n            data-kind={entry.kind}\n          >\n            <i className=\"size-[7px] rounded-full bg-(--kind)\" aria-hidden=\"true\" />\n            {kind.label}\n          </span>\n          <span className=\"text-base leading-body font-medium text-ink\">{entry.title}</span>\n          <span className=\"max-w-[62ch] text-sm leading-body text-ink-2\">{entry.summary}</span>\n        </span>\n        <motion.span\n          className={cn(\n            \"mt-px grid size-5 place-items-center text-ink-3 transition-[color] duration-standard ease-standard group-hover/row:text-ink max-[640px]:col-start-2 max-[640px]:row-start-1 max-[640px]:mt-0\",\n            open && \"text-ink\",\n          )}\n          aria-hidden=\"true\"\n          initial={false}\n          animate={{ rotate: open ? 180 : 0 }}\n          transition={reduce ? instant : motionTokens.spring.snappy}\n        >\n          <ChevronDown size={16} />\n        </motion.span>\n      </button>\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            key=\"panel\"\n            id={panelId}\n            className=\"overflow-hidden\"\n            initial={{ height: 0 }}\n            animate={{ height: \"auto\" }}\n            exit={{ height: 0, transition: reduce ? instant : exitSpring }}\n            transition={reduce ? instant : motionTokens.spring.smooth}\n          >\n            <motion.div\n              className=\"pt-0 pr-[72px] pb-7 pl-[156px] max-[640px]:px-5 max-[640px]:pb-6\"\n              initial={{ opacity: 0, y: 8, filter: blurSoft }}\n              animate={{ opacity: 1, y: 0, filter: none }}\n              exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}\n              transition={\n                reduce\n                  ? { duration: 0, opacity: { duration: motionTokens.duration.instant } }\n                  : {\n                      y: { ...motionTokens.spring.smooth, delay: 0.06 },\n                      opacity: { duration: motionTokens.duration.standard, delay: 0.06 },\n                      filter: { duration: motionTokens.duration.standard, delay: 0.06 },\n                    }\n              }\n            >\n              <ul className=\"m-0 grid max-w-[62ch] list-none gap-1.5 p-0\">\n                {entry.details.map((detail) => (\n                  <li\n                    key={detail}\n                    className=\"relative pl-4 text-sm leading-body text-ink-2 before:absolute before:top-[.75em] before:left-px before:h-px before:w-1.5 before:bg-ink-3 before:content-['']\"\n                  >\n                    {detail}\n                  </li>\n                ))}\n              </ul>\n              {entry.media && <EntryMedia media={entry.media} />}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.li>\n  );\n}\n\nexport interface ChangelogFeedProps {\n  /** Release notes, newest first. Each entry belongs to one of `months` by key. */\n  entries?: ChangelogEntry[];\n  /** Months shown in the month bar, newest first. */\n  months?: ChangelogMonth[];\n  title?: string;\n  /** One line under the title, such as the latest release. */\n  subtitle?: string;\n  /** Closing line at the end of the list. */\n  endNote?: string;\n  /** Entry ids open on first render. Defaults to the newest entry. */\n  defaultOpen?: string[];\n  className?: string;\n}\n\nexport function ChangelogFeed({\n  entries = exampleEntries,\n  months = exampleMonths,\n  title = \"Changelog\",\n  subtitle = \"CapyDB 1.4 shipped on 18 Sep 2026\",\n  endNote = \"That is everything since CapyDB 0.7 in June.\",\n  defaultOpen,\n  className,\n}: ChangelogFeedProps = {}) {\n  const uid = useId();\n  const totalCount = entries.length;\n  const kindCounts = useMemo(\n    () =>\n      kinds.reduce<Record<Kind, number>>(\n        (counts, kind) => ({ ...counts, [kind.id]: entries.filter((entry) => entry.kind === kind.id).length }),\n        { new: 0, improved: 0, fixed: 0 },\n      ),\n    [entries],\n  );\n  const monthOrder = useCallback((key: string) => months.findIndex((month) => month.key === key), [months]);\n  const reduce = useReducedMotion() ?? false;\n  const [filters, setFilters] = useState<Kind[]>([]);\n  const [open, setOpen] = useState<string[]>(() => defaultOpen ?? (entries[0] ? [entries[0].id] : []));\n  const [active, setActive] = useState({ key: months[0]?.key ?? \"\", direction: 1 });\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const groupRefs = useRef(new Map<string, HTMLElement>());\n  const frame = useRef(0);\n\n  const visible = useMemo(\n    () => entries.filter((entry) => filters.length === 0 || filters.includes(entry.kind)),\n    [entries, filters],\n  );\n  const groups = useMemo(\n    () =>\n      months\n        .map((month) => ({ ...month, items: visible.filter((entry) => entry.month === month.key) }))\n        .filter((group) => group.items.length > 0),\n    [months, visible],\n  );\n  const current = groups.find((group) => group.key === active.key) ?? groups[0];\n\n  const sync = useCallback(() => {\n    const scroller = scrollRef.current;\n    if (!scroller || groups.length === 0) return;\n    const line = scroller.getBoundingClientRect().top + 28;\n    let key = groups[0]!.key;\n    for (const group of groups) {\n      const element = groupRefs.current.get(group.key);\n      if (element && element.getBoundingClientRect().top <= line) key = group.key;\n    }\n    if (scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2)\n      key = groups[groups.length - 1]!.key;\n    setActive((previous) =>\n      previous.key === key ? previous : { key, direction: monthOrder(key) > monthOrder(previous.key) ? 1 : -1 },\n    );\n  }, [groups, monthOrder]);\n\n  const onScroll = useCallback(() => {\n    if (frame.current) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = 0;\n      sync();\n    });\n  }, [sync]);\n\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const observer = new ResizeObserver(() => onScroll());\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [onScroll]);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  function toggleFilter(kind: Kind) {\n    setFilters((previous) =>\n      previous.includes(kind) ? previous.filter((item) => item !== kind) : [...previous, kind],\n    );\n  }\n  function toggleEntry(id: string) {\n    setOpen((previous) => (previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id]));\n  }\n  function jumpTo(key: string) {\n    const scroller = scrollRef.current;\n    const element = groupRefs.current.get(key);\n    if (!scroller || !element) return;\n    const top = scroller.scrollTop + element.getBoundingClientRect().top - scroller.getBoundingClientRect().top;\n    scroller.scrollTo({ top: Math.max(0, top - 1), behavior: reduce ? \"auto\" : \"smooth\" });\n  }\n\n  const shownLabel = filters.length === 0 ? `${totalCount} updates` : `${visible.length} of ${totalCount} updates`;\n  const layout: Transition = reduce ? instant : motionTokens.spring.smooth;\n\n  return (\n    <section\n      className={cn(\n        \"mx-auto w-[min(100%,920px)] overflow-hidden rounded-panel border border-line bg-surface font-body tracking-body text-ink motion-reduce:[&_*]:[transition-duration:0ms]!\",\n        className,\n      )}\n      aria-labelledby={`${uid}-title`}\n    >\n      <header className=\"flex items-start justify-between gap-x-6 gap-y-4 px-7 pt-8 pb-5 max-[640px]:flex-col max-[640px]:gap-[18px] max-[640px]:px-5 max-[640px]:pt-[26px] max-[640px]:pb-[26px]\">\n        <div className=\"min-w-0\">\n          <h2 id={`${uid}-title`} className=\"m-0 font-display text-3xl leading-display font-normal tracking-display\">\n            {title}\n          </h2>\n          {subtitle && <p className=\"mt-2 mb-0 text-sm leading-body text-ink-2\">{subtitle}</p>}\n        </div>\n        <SubscribeControl reduce={reduce} />\n      </header>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-3 px-7 pt-1.5 pb-5 max-[640px]:px-5 max-[640px]:pt-1 max-[640px]:pb-[18px]\">\n        <fieldset className=\"flex min-w-0 flex-wrap items-center gap-2\" aria-label=\"Filter by type\">\n          {kinds.map((kind) => {\n            const on = filters.includes(kind.id);\n            return (\n              <button\n                key={kind.id}\n                type=\"button\"\n                className={cn(\n                  \"group/chip inline-flex h-[34px] cursor-pointer items-center gap-2 rounded-button border border-line pr-[13px] pl-3 font-body text-sm/[1] font-medium text-ink-2 [transition:background-color_var(--duration-standard)_var(--ease-standard),border-color_var(--duration-standard)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:not-aria-pressed:border-line-strong hover:not-aria-pressed:text-ink motion-safe:active:[transform:scale(.97)] aria-pressed:border-ink aria-pressed:bg-ink aria-pressed:text-page\",\n                  kindColors[kind.id],\n                )}\n                data-kind={kind.id}\n                aria-pressed={on}\n                onClick={() => toggleFilter(kind.id)}\n              >\n                <motion.span\n                  className=\"grid flex-none place-items-center overflow-hidden rounded-full bg-(--kind) transition-[background-color] duration-standard ease-standard group-aria-pressed/chip:bg-(--mark)\"\n                  aria-hidden=\"true\"\n                  initial={false}\n                  animate={{ width: on ? 16 : 7, height: on ? 16 : 7 }}\n                  transition={reduce ? instant : motionTokens.spring.morph}\n                >\n                  <motion.span\n                    className=\"grid place-items-center text-(--tick)\"\n                    initial={false}\n                    animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.4 }}\n                    transition={reduce ? instant : { ...motionTokens.spring.snappy, delay: on ? 0.05 : 0 }}\n                  >\n                    <Check size={11} strokeWidth={2.6} />\n                  </motion.span>\n                </motion.span>\n                <span>{kind.label}</span>\n                <span className=\"text-xs/[inherit] text-ink-3 tabular-nums transition-[color] duration-standard ease-standard group-aria-pressed/chip:text-[color-mix(in_srgb,var(--page)_62%,transparent)]\">\n                  {kindCounts[kind.id]}\n                </span>\n              </button>\n            );\n          })}\n          <AnimatePresence initial={false}>\n            {filters.length > 0 && (\n              <motion.button\n                key=\"clear\"\n                type=\"button\"\n                className=\"h-[34px] cursor-pointer rounded-button px-2 font-body text-sm/[1] font-medium text-ink-2 hover:text-ink\"\n                onClick={() => setFilters([])}\n                initial={{ opacity: 0, x: -6, filter: blurSubtle }}\n                animate={{ opacity: 1, x: 0, filter: none }}\n                exit={{\n                  opacity: 0,\n                  x: -4,\n                  filter: blurSubtle,\n                  transition: { duration: motionTokens.duration.instant },\n                }}\n                transition={reduce ? instant : motionTokens.spring.snappy}\n              >\n                Show all\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </fieldset>\n        <p\n          className=\"m-0 inline-flex items-baseline gap-1 text-sm/[inherit] whitespace-nowrap text-ink-3 tabular-nums max-[480px]:hidden [&>span:last-child]:inline-block\"\n          aria-hidden=\"true\"\n        >\n          <motion.span className=\"inline-flex\" layout=\"position\" transition={layout}>\n            <RollingNumber value={visible.length} reduce={reduce} />\n          </motion.span>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={filters.length === 0 ? \"all\" : \"some\"}\n              initial={{ opacity: 0, filter: blurSubtle }}\n              animate={{ opacity: 1, filter: none }}\n              exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}\n              transition={reduce ? instant : { duration: motionTokens.duration.standard }}\n            >\n              {filters.length === 0 ? \"updates\" : `of ${totalCount} updates`}\n            </motion.span>\n          </AnimatePresence>\n        </p>\n        <output className=\"sr-only\" aria-live=\"polite\">{`Showing ${shownLabel}`}</output>\n      </div>\n\n      <div className=\"flex min-h-[60px] items-center justify-between gap-4 border-y border-line px-7 py-2.5 max-[640px]:px-5\">\n        <div className=\"flex min-w-0 items-baseline gap-3\" aria-hidden=\"true\">\n          <span className=\"relative inline-flex font-body text-lg leading-body font-medium tracking-body whitespace-nowrap max-[480px]:text-base [&>span]:inline-block\">\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={active.direction}>\n              <motion.span\n                key={current?.key ?? \"none\"}\n                custom={active.direction}\n                variants={rise}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={reduce ? instant : motionTokens.spring.morph}\n              >\n                {current ? (\n                  <>\n                    {current.label.split(\" \")[0]}\n                    <span className=\"inline max-[480px]:hidden\"> {current.label.split(\" \")[1]}</span>\n                  </>\n                ) : (\n                  \"No updates\"\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <motion.span\n            className=\"inline-flex items-baseline gap-1 text-sm/[inherit] whitespace-nowrap text-ink-3 tabular-nums max-[480px]:hidden\"\n            layout=\"position\"\n            transition={layout}\n          >\n            <RollingNumber value={current?.items.length ?? 0} reduce={reduce} />\n            {current?.items.length === 1 ? \"update\" : \"updates\"}\n          </motion.span>\n        </div>\n        <nav className=\"flex flex-none gap-0.5 rounded-button bg-hover p-[3px]\" aria-label=\"Jump to month\">\n          {groups.map((group) => {\n            const selected = group.key === current?.key;\n            return (\n              <button\n                key={group.key}\n                type=\"button\"\n                aria-label={`Jump to ${group.label}`}\n                aria-current={selected ? \"true\" : undefined}\n                className=\"relative h-7 min-w-[42px] cursor-pointer rounded-button px-2.5 font-body text-xs/[1] font-medium text-ink-2 transition-[color] duration-standard ease-standard hover:text-ink aria-[current=true]:text-ink max-[480px]:min-w-0 max-[480px]:px-[9px]\"\n                onClick={() => jumpTo(group.key)}\n              >\n                {selected && (\n                  <motion.span\n                    layoutId={`${uid}-month`}\n                    className=\"absolute inset-0 rounded-button bg-surface shadow-[0_0_0_1px_var(--line),var(--shadow-resting)]\"\n                    transition={reduce ? instant : motionTokens.spring.morph}\n                  />\n                )}\n                <span className=\"relative z-1\">{group.short}</span>\n              </button>\n            );\n          })}\n        </nav>\n      </div>\n\n      <motion.div\n        layoutScroll\n        ref={scrollRef}\n        className=\"relative h-[560px] overflow-y-auto overscroll-contain outline-none [scrollbar-width:thin] [-webkit-mask-image:linear-gradient(to_bottom,#000_calc(100%_-_36px),transparent)] [mask-image:linear-gradient(to_bottom,#000_calc(100%_-_36px),transparent)] max-[640px]:h-[520px]\"\n        onScroll={onScroll}\n        tabIndex={0}\n        aria-label=\"Release notes\"\n      >\n        <div ref={listRef} className=\"relative\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {groups.map((group, index) => (\n              <motion.section\n                key={group.key}\n                layout=\"position\"\n                className=\"relative\"\n                aria-labelledby={`${uid}-${group.key}`}\n                ref={(element: HTMLElement | null) => {\n                  if (element) groupRefs.current.set(group.key, element);\n                  else groupRefs.current.delete(group.key);\n                }}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit } }}\n                transition={{\n                  layout,\n                  opacity: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.standard },\n                }}\n              >\n                <h3\n                  id={`${uid}-${group.key}`}\n                  className={\n                    index === 0\n                      ? \"sr-only\"\n                      : \"m-0 border-b border-line-soft px-7 pt-[30px] pb-2.5 text-sm/[inherit] font-medium text-ink-3 max-[640px]:px-5\"\n                  }\n                >\n                  {group.label}\n                </h3>\n                <ul className=\"relative m-0 list-none p-0\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {group.items.map((entry) => (\n                      <EntryRow\n                        key={entry.id}\n                        entry={entry}\n                        open={open.includes(entry.id)}\n                        onToggle={() => toggleEntry(entry.id)}\n                        reduce={reduce}\n                      />\n                    ))}\n                  </AnimatePresence>\n                </ul>\n              </motion.section>\n            ))}\n          </AnimatePresence>\n          <motion.p\n            layout=\"position\"\n            transition={{ layout }}\n            className=\"m-0 px-7 pt-8 pb-14 text-center text-sm/[inherit] text-ink-3\"\n          >\n            {endNote}\n          </motion.p>\n        </div>\n      </motion.div>\n    </section>\n  );\n}\n\nexport default ChangelogFeed;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/changelog-feed/changelog-feed.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/changelog-feed",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "ChangelogFeed",
    "docs": "https://irisui.dev/components/changelog-feed"
  }
}
