{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "changelog-feed",
  "title": "Changelog Feed",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A release-notes feed with a sticky date and version column, change-type labels, optional media, filter chips with counts and show-more for long releases.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/changelog-feed.tsx",
      "content": "// Ballmac UI: Changelog Feed. https://ui.ballmac.com/components/changelog-feed\n\"use client\";\n\nimport * as React from \"react\";\nimport { Bug, ChevronDown, Sparkles, Trash2, Wand2 } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useLocale, useMessages, defineMessage, type Message } from \"@/lib/ballmac/i18n\";\n\ntype ChangeType = \"new\" | \"improved\" | \"fixed\" | \"removed\";\n\ntype ChangelogChange = {\n  type: ChangeType;\n  /** One sentence describing the change. */\n  text: string;\n};\n\ntype ChangelogEntry = {\n  /** Unique id. */\n  id: string;\n  /** Release version, for example \"2.4.0\". */\n  version?: string;\n  /** ISO date (YYYY-MM-DD). */\n  date: string;\n  /** Headline of the release. */\n  title: string;\n  /** One or two sentences about the release. */\n  summary?: string;\n  /** The individual changes. */\n  changes: ChangelogChange[];\n  /** An image, video or illustration shown under the summary. */\n  media?: React.ReactNode;\n};\n\ntype ChangelogFeedProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Releases, newest first. */\n  entries: ChangelogEntry[];\n  /** Show filter chips with counts. */\n  filterable?: boolean;\n  /** Changes shown before \"Show more\". */\n  collapsedCount?: number;\n  /** Locale for dates. Fixed by default so server and browser match. */\n  locale?: string;\n};\n\nconst TYPES: Record<ChangeType, { label: Message; icon: typeof Sparkles; chip: string; tone: string }> = {\n  new: { label: defineMessage(\"changelog-feed.TYPES.new\", \"New\"), icon: Sparkles, chip: \"bg-chart-2/12 text-foreground\", tone: \"text-chart-2\" },\n  improved: { label: defineMessage(\"changelog-feed.TYPES.improved\", \"Improved\"), icon: Wand2, chip: \"bg-chart-1/12 text-foreground\", tone: \"text-chart-1\" },\n  fixed: { label: defineMessage(\"changelog-feed.TYPES.fixed\", \"Fixed\"), icon: Bug, chip: \"bg-chart-3/15 text-foreground\", tone: \"text-chart-3\" },\n  removed: { label: defineMessage(\"changelog-feed.TYPES.removed\", \"Removed\"), icon: Trash2, chip: \"bg-muted text-foreground\", tone: \"text-muted-foreground\" },\n};\n\nfunction EntryCard({\n  entry,\n  filter,\n  collapsedCount,\n  locale,\n  latest,\n}: {\n  entry: ChangelogEntry;\n  filter: ChangeType | \"all\";\n  collapsedCount: number;\n  locale: string;\n  latest: boolean;\n}) {\n  const msg = useMessages()\n  const reduce = useReducedMotion();\n  const [expanded, setExpanded] = React.useState(false);\n  const listId = React.useId();\n  const changes = filter === \"all\" ? entry.changes : entry.changes.filter((c) => c.type === filter);\n  const visible = expanded ? changes : changes.slice(0, collapsedCount);\n  const hidden = changes.length - visible.length;\n  const date = new Intl.DateTimeFormat(locale, { dateStyle: \"long\", timeZone: \"UTC\" }).format(new Date(`${entry.date}T00:00:00Z`));\n  const titleId = `${listId}-title`;\n\n  return (\n    <motion.article\n      layout={!reduce}\n      initial={reduce ? false : { opacity: 0, y: 12 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n      transition={spring.gentle}\n      aria-labelledby={titleId}\n      className=\"relative grid gap-4 md:grid-cols-[9.5rem_minmax(0,1fr)] md:gap-8\"\n    >\n      <div className=\"md:sticky md:top-6 md:self-start\">\n        <time dateTime={entry.date} className=\"text-sm font-medium tabular-nums\">\n          {date}\n        </time>\n        <div className=\"mt-2 flex flex-wrap items-center gap-1.5\">\n          {entry.version && (\n            <span className=\"rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-medium\">v{entry.version}</span>\n          )}\n          {latest && (\n            <span className=\"rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase\">\n              {msg(\"changelog-feed.latest\", \"Latest\")}\n            </span>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-2xl border bg-card p-5 text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04)] sm:p-6\">\n        <h3 id={titleId} className=\"text-lg font-semibold tracking-tight text-balance\">\n          {entry.title}\n        </h3>\n        {entry.summary && <p className=\"mt-1.5 text-sm leading-relaxed text-muted-foreground\">{entry.summary}</p>}\n        {entry.media && <div className=\"mt-4 overflow-hidden rounded-xl border bg-muted/40\">{entry.media}</div>}\n        <ul id={listId} className=\"mt-4 grid gap-2.5\">\n          {visible.map((change, i) => {\n            const t = TYPES[change.type];\n            const Icon = t.icon;\n            return (\n              <li key={`${change.type}-${i}`} className=\"flex items-start gap-3 text-sm leading-relaxed\">\n                <span className={cn(\"mt-0.5 inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-semibold\", t.chip)}>\n                  <Icon aria-hidden=\"true\" className={cn(\"size-3\", t.tone)} />\n                  {msg.of(t.label)}\n                </span>\n                <span>{change.text}</span>\n              </li>\n            );\n          })}\n        </ul>\n        {(hidden > 0 || expanded) && changes.length > collapsedCount && (\n          <button\n            type=\"button\"\n            aria-expanded={expanded}\n            aria-controls={listId}\n            onClick={() => setExpanded((v) => !v)}\n            className=\"mt-4 inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          >\n            {expanded ? \"Show fewer changes\" : `Show ${hidden} more change${hidden === 1 ? \"\" : \"s\"}`}\n            <ChevronDown aria-hidden=\"true\" className={cn(\"size-4 transition-transform duration-200 motion-reduce:transition-none\", expanded && \"rotate-180\")} />\n          </button>\n        )}\n      </div>\n    </motion.article>\n  );\n}\n\n/**\n * A release-notes feed: a sticky date and version column beside a card per release, with change-type labels that pair\n * an icon and a word, optional media, filter chips with counts, and \"show more\" for long releases.\n */\nfunction ChangelogFeed({\n  entries,\n  filterable = true,\n  collapsedCount = 4,\n  locale,\n  className,\n  ...props\n}: ChangelogFeedProps) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const msg = useMessages()\n  const [filter, setFilter] = React.useState<ChangeType | \"all\">(\"all\");\n  const counts = entries.reduce(\n    (acc, e) => {\n      e.changes.forEach((c) => (acc[c.type] += 1));\n      return acc;\n    },\n    { new: 0, improved: 0, fixed: 0, removed: 0 } as Record<ChangeType, number>,\n  );\n  const shown = entries.filter((e) => filter === \"all\" || e.changes.some((c) => c.type === filter));\n  const types = (Object.keys(TYPES) as ChangeType[]).filter((t) => counts[t] > 0);\n\n  return (\n    <div data-slot=\"changelog-feed\" className={cn(\"grid gap-8\", className)} {...props}>\n      {filterable && (\n        <div role=\"group\" aria-label={msg(\"changelog-feed.filterChanges\", \"Filter changes\")} className=\"flex flex-wrap gap-2\">\n          {([\"all\", ...types] as const).map((t) => {\n            const active = filter === t;\n            return (\n              <button\n                key={t}\n                type=\"button\"\n                aria-pressed={active}\n                onClick={() => setFilter(t)}\n                className=\"inline-flex h-8 items-center gap-2 rounded-full border px-3.5 text-[13px] font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground\"\n              >\n                {t === \"all\" ? msg(\"changelog-feed.all\", \"All\") : msg.of(TYPES[t].label)}\n                <span className=\"text-xs opacity-70 tabular-nums\">{t === \"all\" ? entries.reduce((n, e) => n + e.changes.length, 0) : counts[t]}</span>\n              </button>\n            );\n          })}\n        </div>\n      )}\n      <div className=\"grid gap-10 md:gap-12\" aria-live=\"polite\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {shown.map((entry) => (\n            <EntryCard key={entry.id} entry={entry} filter={filter} collapsedCount={collapsedCount} locale={locale} latest={entry.id === entries[0]?.id} />\n          ))}\n        </AnimatePresence>\n        {shown.length === 0 && <p className=\"text-sm text-muted-foreground\">{msg(\"changelog-feed.noReleasesMatchThisFilter\", \"No releases match this filter.\")}</p>}\n      </div>\n    </div>\n  );\n}\n\nexport { ChangelogFeed, type ChangelogFeedProps, type ChangelogEntry, type ChangelogChange, type ChangeType };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/changelog-feed.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "changelog",
      "release notes",
      "updates",
      "timeline"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "entries[] with {id,version,date (ISO),title,summary,changes:[{type,text}],media}. Types: new | improved | fixed | removed. Dates format in UTC.",
      "whenToUse": [
        "Product changelog pages",
        "What's-new surfaces"
      ],
      "whenNotToUse": [
        "Activity of people on a record; use activity-feed",
        "A single status message; use banner"
      ],
      "composesWith": [
        "timeline",
        "activity-feed",
        "badge"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Filter chips and show-more are buttons with aria-pressed / aria-expanded"
        },
        {
          "keys": "Screen readers",
          "action": "Each release is an article with a time element; change types have text labels"
        }
      ],
      "customization": [
        "filterable",
        "collapsedCount",
        "locale",
        "media"
      ]
    },
    "examples": [
      "changelog-feed-demo",
      "changelog-feed-states"
    ],
    "url": "https://ui.ballmac.com/components/changelog-feed"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}