{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-feed",
  "title": "Activity Feed",
  "description": "A grouped activity stream — consecutive entries that share a verb and a target fold into one line, with day separators, an unread boundary and four data states.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/activity-feed.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, ChevronDown, Inbox } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  ActivityFeedActor,\n  ActivityFeedData,\n  ActivityFeedItem,\n  ActivityFeedTarget,\n} from \"./activity-feed.contract\"\n\nconst DEFAULT_LOCALE = \"en-US\"\n/**\n * UTC by default, on purpose: day bucketing decides which separator rows exist, so a server\n * rendering in one zone and a browser in another would disagree about the *structure* of the\n * feed, not just about a string. Pass the viewer's zone from a client boundary or their profile.\n */\nconst DEFAULT_TIME_ZONE = \"UTC\"\nconst DEFAULT_MAX_VISIBLE = 8\nconst DEFAULT_SKELETON_ROWS = 4\n/** Faces shown in the stack before the rest turn into a \"+N\" bubble. */\nconst MAX_FACES = 3\n\nconst SECOND = 1000\nconst MINUTE = 60 * SECOND\nconst HOUR = 60 * MINUTE\nconst DAY = 24 * HOUR\nconst WEEK = 7 * DAY\nconst MONTH = 30 * DAY\nconst YEAR = 365 * DAY\n\n/**\n * Consecutive entries further apart than this never merge, even when actor, verb and target\n * all match: two comments on the same document eight hours apart are two visits, not one burst.\n */\nconst DEFAULT_GROUP_WITHIN_MS = 6 * HOUR\n\n/** Entries whose `at` does not parse land in their own trailing bucket. */\nconst UNKNOWN_DAY = \"unknown\"\n\n/**\n * Gregorian + latin digits are forced so a day key is comparable no matter the locale — an\n * ar-EG formatter would otherwise emit arabic-indic digits and no two keys would ever match.\n */\nconst DAY_KEY_OPTIONS = {\n  year: \"numeric\",\n  month: \"2-digit\",\n  day: \"2-digit\",\n  calendar: \"gregory\",\n  numberingSystem: \"latn\",\n} as const\n\n/** Used only to turn \"the civil day before X\" into a key; never formats anything on screen. */\nconst UTC_DAY_FORMAT = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeZone: \"UTC\", ...DAY_KEY_OPTIONS })\n\n/** Everything that can take focus and is not inside the feed — the Ctrl+Home / Ctrl+End targets. */\nconst FOCUSABLE_SELECTOR = \"a[href],button,input,select,textarea,summary,[tabindex]\"\n\n/**\n * How consecutive entries are folded into one row.\n *\n * - `\"verb-target\"` (default) — many people, one thing: \"Ada and 3 others commented on Q3 plan\".\n * - `\"actor-verb\"` — one person, many things: \"Ada assigned issue Flaky test and 3 more\".\n * - `\"none\"` — every entry keeps its own row.\n */\nexport type ActivityCollapseMode = \"verb-target\" | \"actor-verb\" | \"none\"\n\nexport interface ActivityFeedLabels {\n  /** day separators */\n  today: string\n  yesterday: string\n  undated: string\n  /** the read/unread divider — which one shows depends on `order` */\n  newDivider: string\n  earlierDivider: string\n  /** screen-reader prefix on every unread row, because the dot and the tint are visual only */\n  unread: string\n  /** header pill, `{count}` is replaced */\n  newCount: string\n  markRead: string\n  marked: string\n  /** stands in for an actor whose name is blank — a sentence with no subject is not a sentence */\n  unknownActor: string\n  /** collapsed-row wording, `{count}` is replaced */\n  others: string\n  moreTargets: string\n  expand: string\n  collapse: string\n  /** footer */\n  showMore: string\n  loadMore: string\n  /** the three non-ready branches */\n  loading: string\n  empty: string\n  error: string\n  retry: string\n}\n\nconst DEFAULT_LABELS: ActivityFeedLabels = {\n  today: \"Today\",\n  yesterday: \"Yesterday\",\n  undated: \"Undated\",\n  newDivider: \"New\",\n  earlierDivider: \"Earlier\",\n  unread: \"Unread\",\n  newCount: \"{count} new\",\n  markRead: \"Mark all as read\",\n  marked: \"Marked as read\",\n  unknownActor: \"Someone\",\n  others: \"{count} others\",\n  moreTargets: \"and {count} more\",\n  expand: \"Show all {count} updates\",\n  collapse: \"Hide the other updates\",\n  showMore: \"Show {count} more\",\n  loadMore: \"Load older activity\",\n  loading: \"Loading activity\",\n  empty: \"Nothing has happened here yet.\",\n  error: \"Could not load the activity feed.\",\n  retry: \"Try again\",\n}\n\n/**\n * One rendered row: the entries that were folded together plus everything the sentence needs.\n * Handed to `renderSummary` so a rewritten sentence never has to redo the grouping maths.\n */\nexport interface ActivityGroup {\n  /** stable id — the first entry's id in reading order; also the expand-state key */\n  id: string\n  /** the folded entries, in reading order */\n  items: ActivityFeedItem[]\n  /** distinct actors, first appearance first */\n  actors: ActivityFeedActor[]\n  /** distinct targets, first appearance first (exactly one in \"verb-target\" mode) */\n  targets: ActivityFeedTarget[]\n  /** the raw verb every entry in the group shares */\n  verb: string\n  /** every entry in the group is newer than `lastReadAt` — a group never straddles the boundary */\n  unread: boolean\n  /** newest parseable instant in the group; null when none of its entries is dated */\n  at: number | null\n  /** civil-day key in the feed's `timeZone`, or `\"unknown\"` */\n  dayKey: string\n  /** what decided the fold; null when `collapse=\"none\"` */\n  collapseKey: string | null\n  /** instant of the entry appended last — the window anchor the next entry is measured against */\n  lastAt: number | null\n}\n\nexport interface ActivityFeedRenderContext {\n  /** the resolved verb — `verbLabels[verb]` if mapped, otherwise the humanised key */\n  verbLabel: string\n  /** the subject the default sentence would have used (links when `actor.href` is set) */\n  actors: React.ReactNode\n  /** the object the default sentence would have used (a link when `target.href` is set) */\n  target: React.ReactNode\n  labels: ActivityFeedLabels\n}\n\nexport interface ActivityFeedProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\">,\n    ActivityFeedData {\n  /**\n   * The reference \"now\" for relative times and the Today / Yesterday separators. Required,\n   * because the component never calls `new Date()`: that is impure at render time and it makes\n   * the server and the browser disagree about which day an entry belongs to.\n   */\n  now: string | number | Date\n  /**\n   * Everything strictly newer than this instant is unread. Omit it and the feed has no unread\n   * boundary at all — no dot, no divider, no \"mark all as read\" button. Undated entries can\n   * never be compared, so they always count as read.\n   */\n  lastReadAt?: string | number | Date\n  /** Reading order. Default \"newest-first\". */\n  order?: \"newest-first\" | \"oldest-first\"\n  /** Which consecutive entries fold into one row. Default \"verb-target\". */\n  collapse?: ActivityCollapseMode\n  /** Consecutive entries further apart than this never fold. Default 6 hours. */\n  groupWithinMs?: number\n  /** IANA zone for day bucketing and absolute timestamps. Defaults to UTC (see the note above). */\n  timeZone?: string\n  /** BCP-47 tag for every `Intl` call. Defaults to \"en-US\" — never `undefined`. */\n  locale?: string\n  /** Rows shown before the footer button; each click reveals another `maxVisible`. Clamped >= 1. */\n  maxVisible?: number\n  /** Fires when the button is clicked and every buffered group is already on screen. */\n  onLoadMore?: () => void\n  /**\n   * Fires at most once per unread boundary. Omit it and no button is painted — a \"mark all as\n   * read\" that marks nothing is a lie.\n   */\n  onMarkRead?: () => void\n  /** Maps a machine verb key to prose: `{ \"issue.comment\": \"commented on\" }`. */\n  verbLabels?: Record<string, string>\n  /** Replaces the sentence only; avatars, timestamp, meta line and the disclosure stay. */\n  renderSummary?: (group: ActivityGroup, context: ActivityFeedRenderContext) => React.ReactNode\n  /** Every string the component can say. Merged over the defaults, so partial overrides are fine. */\n  labels?: Partial<ActivityFeedLabels>\n  /** Show each entry's `meta` excerpt. The timestamp always renders. Default true. */\n  showMeta?: boolean\n  /** Group ids whose folded entries start expanded. Read once, on mount. */\n  defaultExpandedIds?: string[]\n  errorMessage?: React.ReactNode\n  /** Omit it and the error branch shows no retry affordance at all. */\n  onRetry?: () => void\n  /** Replaces the whole `status=\"empty\"` body. */\n  emptyState?: React.ReactNode\n  skeletonRows?: number\n  /** Accessible name of the `role=\"feed\"` region. Default \"Activity\". */\n  label?: string\n}\n\ninterface Formatters {\n  dayParts: Intl.DateTimeFormat\n  dayLabel: Intl.DateTimeFormat\n  absolute: Intl.DateTimeFormat\n  relative: Intl.RelativeTimeFormat\n  locale: string\n}\n\nfunction buildFormatters(locale: string, timeZone: string): Formatters {\n  return {\n    dayParts: new Intl.DateTimeFormat(locale, { timeZone, ...DAY_KEY_OPTIONS }),\n    dayLabel: new Intl.DateTimeFormat(locale, {\n      timeZone,\n      weekday: \"short\",\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n    }),\n    // dateStyle/timeStyle cannot be combined with timeZoneName, so the parts are spelled out —\n    // and the zone IS spelled out, because \"9:41\" means nothing without one.\n    absolute: new Intl.DateTimeFormat(locale, {\n      timeZone,\n      year: \"numeric\",\n      month: \"short\",\n      day: \"numeric\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n      timeZoneName: \"short\",\n    }),\n    relative: new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }),\n    locale,\n  }\n}\n\n/** A typo'd locale or IANA zone makes `Intl` throw at construction — that must not take the tree down. */\nfunction safeFormatters(locale: string, timeZone: string): Formatters {\n  try {\n    return buildFormatters(locale, timeZone)\n  } catch {\n    try {\n      return buildFormatters(DEFAULT_LOCALE, timeZone)\n    } catch {\n      return buildFormatters(DEFAULT_LOCALE, DEFAULT_TIME_ZONE)\n    }\n  }\n}\n\nfunction toMs(value: string | number | Date): number | null {\n  const ms = value instanceof Date ? value.getTime() : typeof value === \"number\" ? value : Date.parse(value)\n  return Number.isFinite(ms) ? ms : null\n}\n\nfunction dayKeyOf(format: Intl.DateTimeFormat, ms: number): string {\n  let year = \"\"\n  let month = \"\"\n  let day = \"\"\n  for (const part of format.formatToParts(ms)) {\n    if (part.type === \"year\") year = part.value\n    else if (part.type === \"month\") month = part.value\n    else if (part.type === \"day\") day = part.value\n  }\n  return `${year}-${month}-${day}`\n}\n\n/**\n * The key of the civil day before `ms`. The civil date is lifted onto a UTC-midnight ordinal\n * first, so this is exact day arithmetic — subtracting 24h from the instant lands on the SAME\n * civil day during a 25-hour DST fall-back evening, and \"Yesterday\" would silently disappear\n * once a year.\n */\nfunction previousDayKeyOf(format: Intl.DateTimeFormat, ms: number): string {\n  const [year, month, day] = dayKeyOf(format, ms).split(\"-\").map(Number)\n  return dayKeyOf(UTC_DAY_FORMAT, Date.UTC(year, month - 1, day) - DAY)\n}\n\n/** Coarsest-fit relative label; `numeric: \"auto\"` gives idiomatic \"yesterday\" where a locale has one. */\nfunction formatRelative(diffMs: number, rtf: Intl.RelativeTimeFormat): string {\n  const abs = Math.abs(diffMs)\n  if (abs < 45 * SECOND) return rtf.format(0, \"second\")\n  if (abs < MINUTE) return rtf.format(Math.round(diffMs / SECOND), \"second\")\n  if (abs < HOUR) return rtf.format(Math.round(diffMs / MINUTE), \"minute\")\n  if (abs < DAY) return rtf.format(Math.round(diffMs / HOUR), \"hour\")\n  if (abs < WEEK) return rtf.format(Math.round(diffMs / DAY), \"day\")\n  if (abs < MONTH) return rtf.format(Math.round(diffMs / WEEK), \"week\")\n  if (abs < YEAR) return rtf.format(Math.round(diffMs / MONTH), \"month\")\n  return rtf.format(Math.round(diffMs / YEAR), \"year\")\n}\n\n/** \"issue.comment\" / \"pull_request-merge\" → \"issue comment\" / \"pull request merge\". */\nfunction humanizeVerb(verb: string): string {\n  const spaced = verb\n    .replace(/[._-]+/g, \" \")\n    .replace(/([a-z\\d])([A-Z])/g, \"$1 $2\")\n    .trim()\n  return spaced.length > 0 ? spaced.toLowerCase() : verb\n}\n\n/** A blank name would leave the sentence with no subject at all, so it gets a stand-in word. */\nfunction nameOf(actor: ActivityFeedActor, labels: ActivityFeedLabels): string {\n  return actor.name.trim().length > 0 ? actor.name : labels.unknownActor\n}\n\nfunction initialsOf(name: string): string {\n  const parts = name.trim().split(/\\s+/).filter(Boolean)\n  if (parts.length === 0) return \"?\"\n  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()\n}\n\nfunction clampCount(value: number | undefined, fallback: number): number {\n  if (value === undefined || !Number.isFinite(value)) return fallback\n  return Math.max(1, Math.floor(value))\n}\n\n/** `{count}` is the only placeholder, so a translated label can put the number anywhere. */\nfunction fill(template: string, count: number): string {\n  return template.replace(\"{count}\", String(count))\n}\n\n/** A target with no id is identified by its label — a rename then reads as a new thing, which it is. */\nfunction targetIdentity(target: ActivityFeedTarget): string {\n  return target.id ?? `label:${target.label}`\n}\n\n/**\n * Joins the two halves of a collapse key. The first half is length-prefixed so no verb, id or\n * label can forge the boundary: \"a|b\" + \"c\" and \"a\" + \"b|c\" must never produce one key. (A raw\n * separator byte would do the same job, but a literal control character in the source makes\n * `file` call it binary and makes `grep` skip the whole file.)\n */\nfunction keyOf(head: string, tail: string): string {\n  return `${head.length}:${head}|${tail}`\n}\n\ninterface JoinedPart {\n  /** position in the input array, or null for a separator the locale inserted */\n  index: number | null\n  value: string\n}\n\n/** One name in the sentence's subject — a real actor, or the \"N others\" phrase standing in for many. */\ninterface SentenceActor {\n  href?: string\n  /** React key: the actor id, or the literal \"others\" */\n  key: string\n  label: string\n}\n\n/**\n * Locale-correct list joining that survives being rebuilt as JSX: `formatToParts` hands back the\n * separators (\"、\" in ja, \" and \" in en, \" y \" in es) as literals and the names as elements, so a\n * name can become a link without hard-coding an English comma anywhere. Elements are re-tagged\n * with their input index, because two people really can share a display name.\n */\nfunction joinParts(locale: string, values: string[]): JoinedPart[] {\n  let cursor = 0\n  try {\n    return new Intl.ListFormat(locale, { style: \"long\", type: \"conjunction\" })\n      .formatToParts(values)\n      .map(part => {\n        if (part.type !== \"element\") return { index: null, value: part.value }\n        const index = cursor\n        cursor += 1\n        return { index, value: part.value }\n      })\n  } catch {\n    const parts: JoinedPart[] = []\n    values.forEach((value, index) => {\n      if (index > 0) parts.push({ index: null, value: index === values.length - 1 ? \" and \" : \", \" })\n      parts.push({ index, value })\n    })\n    return parts\n  }\n}\n\ninterface PreparedItem {\n  item: ActivityFeedItem\n  ms: number | null\n  dayKey: string\n  unread: boolean\n}\n\ninterface BuildOptions {\n  collapse: ActivityCollapseMode\n  dayParts: Intl.DateTimeFormat\n  groupWindow: number\n  lastReadMs: number | null\n  newestFirst: boolean\n}\n\n/**\n * De-dupe → sort → fold consecutive runs. Everything the feed renders is derived here, so the\n * component itself only ever walks a flat list of finished groups.\n */\nfunction buildGroups(items: ActivityFeedItem[], options: BuildOptions): ActivityGroup[] {\n  const { collapse, dayParts, groupWindow, lastReadMs, newestFirst } = options\n\n  // Two rows sharing an id share a React key AND the expand-state key, so expanding one would\n  // silently expand the other. First occurrence wins.\n  const seen = new Set<string>()\n  const prepared: PreparedItem[] = []\n  for (const item of items) {\n    if (seen.has(item.id)) continue\n    seen.add(item.id)\n    const ms = toMs(item.at)\n    prepared.push({\n      item,\n      ms,\n      dayKey: ms === null ? UNKNOWN_DAY : dayKeyOf(dayParts, ms),\n      unread: ms !== null && lastReadMs !== null && ms > lastReadMs,\n    })\n  }\n\n  // Undated entries always sink to the end — they cannot be ordered against anything.\n  prepared.sort((a, b) => {\n    if ((a.ms === null) !== (b.ms === null)) return a.ms === null ? 1 : -1\n    if (a.ms === null || b.ms === null) return 0\n    return newestFirst ? b.ms - a.ms : a.ms - b.ms\n  })\n\n  const groups: ActivityGroup[] = []\n  for (const entry of prepared) {\n    const { actor, target, verb } = entry.item\n    const collapseKey =\n      collapse === \"none\"\n        ? null\n        : collapse === \"actor-verb\"\n          ? keyOf(actor.id, verb)\n          : keyOf(verb, targetIdentity(target))\n\n    const previous = groups[groups.length - 1]\n    const mergeable =\n      previous !== undefined &&\n      collapseKey !== null &&\n      previous.collapseKey === collapseKey &&\n      // A fold never spans a day separator or the unread divider — either would draw a line\n      // straight through the middle of one row.\n      previous.dayKey === entry.dayKey &&\n      previous.unread === entry.unread &&\n      previous.lastAt !== null &&\n      entry.ms !== null &&\n      Math.abs(entry.ms - previous.lastAt) <= groupWindow\n\n    if (mergeable && previous !== undefined) {\n      previous.items.push(entry.item)\n      if (!previous.actors.some(known => known.id === actor.id)) previous.actors.push(actor)\n      if (!previous.targets.some(known => targetIdentity(known) === targetIdentity(target))) {\n        previous.targets.push(target)\n      }\n      if (entry.ms !== null) previous.at = previous.at === null ? entry.ms : Math.max(previous.at, entry.ms)\n      previous.lastAt = entry.ms\n      continue\n    }\n\n    groups.push({\n      id: entry.item.id,\n      items: [entry.item],\n      actors: [actor],\n      targets: [target],\n      verb,\n      unread: entry.unread,\n      at: entry.ms,\n      dayKey: entry.dayKey,\n      collapseKey,\n      lastAt: entry.ms,\n    })\n  }\n\n  return groups\n}\n\n/**\n * The remote image sits ON TOP of the initials, so a slow or dead URL degrades to initials\n * instead of a broken-image glyph. The failure is remembered per URL rather than as a boolean:\n * when the row is reused for another actor the new face gets its own chance. The ref callback\n * re-checks `complete && naturalWidth === 0` because a cached (or already failed) image can\n * finish before React ever attaches `onError`.\n */\nfunction ActorAvatar({ actor, className }: { actor: ActivityFeedActor; className?: string }) {\n  const [failedUrl, setFailedUrl] = React.useState<string | null>(null)\n  const url = actor.avatarUrl\n  const showImage = url !== undefined && url.length > 0 && failedUrl !== url\n\n  return (\n    <span\n      className={cn(\n        // text-foreground, not muted: initials on bg-muted sit at ~4.5:1 in the light theme.\n        \"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full\",\n        \"bg-muted text-xs font-medium text-foreground ring-2 ring-card\",\n        className,\n      )}\n    >\n      <span aria-hidden=\"true\">{initialsOf(actor.name)}</span>\n      {showImage && (\n        // eslint-disable-next-line @next/next/no-img-element -- consumer-supplied remote URL, not a local optimizable asset\n        <img\n          alt=\"\"\n          className=\"absolute inset-0 size-full object-cover\"\n          loading=\"lazy\"\n          onError={() => setFailedUrl(url)}\n          ref={node => {\n            if (node && node.complete && node.naturalWidth === 0) setFailedUrl(url)\n          }}\n          src={url}\n        />\n      )}\n    </span>\n  )\n}\n\n/** Faces are decoration: every name they stand for is already in the sentence next to them. */\nfunction ActorStack({ actors }: { actors: ActivityFeedActor[] }) {\n  const faces = actors.slice(0, MAX_FACES)\n  const overflow = actors.length - faces.length\n\n  return (\n    <span aria-hidden=\"true\" className=\"flex shrink-0 items-start\">\n      {faces.map((actor, index) => (\n        <ActorAvatar actor={actor} className={index > 0 ? \"-ml-3\" : undefined} key={actor.id} />\n      ))}\n      {overflow > 0 && (\n        <span className=\"-ml-3 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-[0.625rem] font-medium text-muted-foreground ring-2 ring-card\">\n          +{overflow}\n        </span>\n      )}\n    </span>\n  )\n}\n\nfunction SkeletonRow() {\n  return (\n    <div className=\"flex min-w-0 gap-3\">\n      <div className=\"size-8 shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2 py-1\">\n        <div className=\"h-3 w-3/5 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n        <div className=\"h-3 w-1/4 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n      </div>\n    </div>\n  )\n}\n\nconst dividerClass = \"flex items-center gap-3 text-xs font-medium\"\nconst linkClass =\n  \"rounded-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\nconst quietButtonClass = cn(\n  \"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs\",\n  \"text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\",\n  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n  // aria-disabled, never the `disabled` attribute: the browser blurs a control the instant it\n  // becomes disabled, so \"Mark all as read\" would drop focus onto <body> on its own click.\n  \"aria-disabled:cursor-default aria-disabled:opacity-60 aria-disabled:hover:bg-transparent\",\n  \"aria-disabled:hover:text-muted-foreground\",\n)\n\n/**\n * A grouped activity stream: who did what to which thing, when — with consecutive entries that\n * share a verb and a target folded into one line, day separators, and an unread boundary.\n *\n * Four decisions shape it:\n *\n * 1. **Folding is run-length, never global.** Only *consecutive* entries merge, and only inside\n *    one civil day, one side of the unread boundary and one time window. A feed that merged\n *    across the whole page would reorder history to make its sentences shorter.\n * 2. **Time is injected.** `now` is a required prop and `timeZone` defaults to UTC, so the day\n *    separators are the same on the server and in the browser.\n * 3. **`role=\"feed\"`, so Page Up / Page Down work.** Rows are articles in a roving tabindex —\n *    one Tab stop for the whole stream, with the ARIA feed keyboard map inside it.\n * 4. **Nothing is painted that cannot act.** No retry without `onRetry`, no \"mark all as read\"\n *    without `onMarkRead`, no footer button without something behind it.\n */\nexport const ActivityFeed = React.forwardRef<HTMLDivElement, ActivityFeedProps>(function ActivityFeed(\n  {\n    items,\n    status,\n    now,\n    lastReadAt,\n    order = \"newest-first\",\n    collapse = \"verb-target\",\n    groupWithinMs = DEFAULT_GROUP_WITHIN_MS,\n    timeZone = DEFAULT_TIME_ZONE,\n    locale = DEFAULT_LOCALE,\n    maxVisible,\n    onLoadMore,\n    onMarkRead,\n    verbLabels,\n    renderSummary,\n    labels,\n    showMeta = true,\n    defaultExpandedIds,\n    errorMessage,\n    onRetry,\n    emptyState,\n    skeletonRows,\n    label = \"Activity\",\n    className,\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId()\n  const feedRef = React.useRef<HTMLDivElement | null>(null)\n  const articleRefs = React.useRef<(HTMLElement | null)[]>([])\n  /**\n   * What should take focus once the next commit has rendered it: a row index, or `\"rescue\"` —\n   * the softer intent used when a control may unmount itself without revealing anything.\n   */\n  const focusIntentRef = React.useRef<number | \"rescue\" | null>(null)\n  /** The one-shot lock for \"mark all as read\" (see `handleMarkRead`). */\n  const markedRef = React.useRef<string | null>(null)\n\n  const [expanded, setExpanded] = React.useState<ReadonlySet<string>>(() => new Set(defaultExpandedIds ?? []))\n  const [extraVisible, setExtraVisible] = React.useState(0)\n  const [activeIndex, setActiveIndex] = React.useState(0)\n  const [markedKey, setMarkedKey] = React.useState<string | null>(null)\n\n  // Focus follows the reveal: the button that was clicked may unmount itself, and focus would\n  // land on <body>. Runs on every commit and disarms itself, so it can never yank focus twice.\n  // Focusing is all it does — the row's own onFocus is what moves the roving tabindex.\n  React.useEffect(() => {\n    const intent = focusIntentRef.current\n    if (intent === null) return\n    focusIntentRef.current = null\n    if (typeof document === \"undefined\") return\n    // The exact row the click uncovered, whenever it really rendered.\n    const revealed = intent === \"rescue\" ? null : articleRefs.current[intent]\n    if (revealed?.isConnected) {\n      revealed.focus()\n      return\n    }\n    // Otherwise fall back to a rescue, which never steals focus: it steps in only when the\n    // control that was activated unmounted itself and left the reader standing on <body>.\n    if (document.activeElement !== document.body) return\n    articleRefs.current.find(node => node?.isConnected)?.focus()\n  })\n\n  // No useMemo below: every value is a pure function of props and the React Compiler memoizes\n  // them. Hand-rolled memoization around Intl makes it bail out.\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const formatters = safeFormatters(locale, timeZone)\n  const nowMs = toMs(now)\n  const lastReadMs = lastReadAt === undefined ? null : toMs(lastReadAt)\n  const newestFirst = order !== \"oldest-first\"\n  const groupWindow = Number.isFinite(groupWithinMs) ? Math.max(0, groupWithinMs) : Number.POSITIVE_INFINITY\n\n  const groups = buildGroups(items, {\n    collapse,\n    dayParts: formatters.dayParts,\n    groupWindow,\n    lastReadMs,\n    newestFirst,\n  })\n\n  const unreadCount = groups.reduce((total, group) => total + (group.unread ? group.items.length : 0), 0)\n  const firstUnreadId = groups.find(group => group.unread)?.id ?? \"\"\n  /** Changes whenever the boundary itself moves, which is what re-arms the one-shot below. */\n  const markKey = `${lastReadMs ?? \"none\"}|${firstUnreadId}|${unreadCount}`\n  const marked = markedKey === markKey\n\n  // The lock is released the moment the rendered boundary stops being the one that was marked.\n  // Keying it forever by value would spend the button permanently: a consumer that moves\n  // `lastReadAt` back (an \"unread\" toggle, a restored snapshot) lands on the SAME key, and the\n  // button would keep saying \"Marked as read\" with a feed full of unread rows underneath it.\n  React.useEffect(() => {\n    if (markedRef.current === null || markedRef.current === markKey) return\n    markedRef.current = null\n    setMarkedKey(null)\n  })\n\n  // maxVisible = 0 or NaN would render nothing and leave a footer button with no rows above it.\n  const cap = clampCount(maxVisible, DEFAULT_MAX_VISIBLE)\n  const visibleCount = Math.min(groups.length, cap + extraVisible)\n  const visibleGroups = groups.slice(0, visibleCount)\n  const hiddenCount = groups.length - visibleCount\n\n  const todayKey = nowMs === null ? null : dayKeyOf(formatters.dayParts, nowMs)\n  const yesterdayKey = nowMs === null ? null : previousDayKeyOf(formatters.dayParts, nowMs)\n\n  const dayLabelOf = (group: ActivityGroup): string => {\n    if (group.dayKey === UNKNOWN_DAY || group.at === null) return text.undated\n    if (group.dayKey === todayKey) return text.today\n    if (group.dayKey === yesterdayKey) return text.yesterday\n    return formatters.dayLabel.format(group.at)\n  }\n\n  /* --------------------------------------------------------------- handlers */\n\n  const toggleGroup = (id: string) => {\n    setExpanded(previous => {\n      const next = new Set(previous)\n      if (next.has(id)) next.delete(id)\n      else next.add(id)\n      return next\n    })\n  }\n\n  /**\n   * One shot per boundary. The lock is a REF read and written inside the same synchronous\n   * handler: two clicks in one tick both run before React re-renders, so a state-only guard\n   * would still read \"not marked\" on the second one and fire the callback twice. State exists\n   * only to repaint the button; `markKey` re-arms it by itself when newer activity arrives.\n   */\n  const handleMarkRead = () => {\n    if (markedRef.current === markKey) return\n    markedRef.current = markKey\n    setMarkedKey(markKey)\n    // Answering with a new `lastReadAt` leaves nothing unread, which unmounts this whole header\n    // — including the button the reader is standing on. Catch the fall onto <body>.\n    focusIntentRef.current = \"rescue\"\n    onMarkRead?.()\n  }\n\n  const handleReveal = () => {\n    // Focus the first row this click reveals, not the button that may be gone afterwards — the\n    // `onLoadMore` branch unmounts it too, because a consumer drops the prop once its cursor is\n    // exhausted, which is exactly what this component asks it to do.\n    focusIntentRef.current = visibleCount\n    if (hiddenCount === 0) onLoadMore?.()\n    // Grow the window either way: the page `onLoadMore` appends must not land behind the button.\n    setExtraVisible(value => value + cap)\n  }\n\n  /* --------------------------------------------------------------- keyboard */\n\n  /** Which row owns focus right now — including focus on a link or button INSIDE a row. */\n  const focusedIndex = (): number => {\n    const active = typeof document === \"undefined\" ? null : document.activeElement\n    if (!active) return activeIndex\n    for (let index = 0; index < visibleGroups.length; index += 1) {\n      const node = articleRefs.current[index]\n      if (node && (node === active || node.contains(active))) return index\n    }\n    return Math.min(activeIndex, visibleGroups.length - 1)\n  }\n\n  const focusArticle = (index: number): boolean => {\n    const target = Math.min(Math.max(index, 0), visibleGroups.length - 1)\n    const node = articleRefs.current[target]\n    if (!node?.isConnected) return false\n    setActiveIndex(target)\n    node.focus()\n    return true\n  }\n\n  /**\n   * Ctrl+Home / Ctrl+End leave the feed entirely, per the ARIA feed pattern: the nearest\n   * tabbable element before or after it. Hidden and `tabindex=\"-1\"` nodes are skipped, and so\n   * is anything that *contains* the feed (an ancestor is \"preceding\" by document position but\n   * jumping to it would land the reader back inside).\n   */\n  const focusOutsideFeed = (direction: \"before\" | \"after\"): boolean => {\n    const feed = feedRef.current\n    if (!feed || typeof document === \"undefined\") return false\n    let best: HTMLElement | null = null\n    for (const node of Array.from(document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))) {\n      if (feed.contains(node) || node.contains(feed)) continue\n      if (node.tabIndex < 0 || node.hasAttribute(\"disabled\") || node.getAttribute(\"aria-hidden\") === \"true\") continue\n      if (node.offsetWidth === 0 && node.offsetHeight === 0 && node.getClientRects().length === 0) continue\n      const position = feed.compareDocumentPosition(node)\n      if (direction === \"before\" && position & Node.DOCUMENT_POSITION_PRECEDING) best = node\n      if (direction === \"after\" && position & Node.DOCUMENT_POSITION_FOLLOWING && best === null) best = node\n    }\n    if (best === null) return false\n    best.focus()\n    return true\n  }\n\n  const handleFeedKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.altKey || event.shiftKey || visibleGroups.length === 0) return\n    const chord = event.ctrlKey || event.metaKey\n    if (chord && event.key === \"End\") {\n      if (focusOutsideFeed(\"after\")) event.preventDefault()\n      return\n    }\n    if (chord && event.key === \"Home\") {\n      if (focusOutsideFeed(\"before\")) event.preventDefault()\n      return\n    }\n    if (chord) return\n    // preventDefault only when focus actually moved — otherwise Page Up / Page Down must keep\n    // doing the native thing (scrolling the page).\n    if (event.key === \"PageDown\") {\n      const index = focusedIndex()\n      if (index >= visibleGroups.length - 1) return\n      if (focusArticle(index + 1)) event.preventDefault()\n      return\n    }\n    if (event.key === \"PageUp\") {\n      const index = focusedIndex()\n      if (index <= 0) return\n      if (focusArticle(index - 1)) event.preventDefault()\n    }\n  }\n\n  /* --------------------------------------------------------------- branches */\n\n  const rootClass = cn(\"flex w-full min-w-0 flex-col gap-3 text-sm\", className)\n\n  if (status === \"loading\") {\n    return (\n      <div aria-busy=\"true\" className={rootClass} ref={ref} {...props}>\n        <span className=\"sr-only\" role=\"status\">\n          {text.loading}\n        </span>\n        <div aria-hidden=\"true\" className=\"flex flex-col gap-4\">\n          {Array.from({ length: clampCount(skeletonRows, DEFAULT_SKELETON_ROWS) }, (_, index) => (\n            <SkeletonRow key={index} />\n          ))}\n        </div>\n      </div>\n    )\n  }\n\n  if (status === \"error\") {\n    return (\n      <div className={rootClass} ref={ref} {...props}>\n        <div\n          className=\"flex flex-col items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-center\"\n          role=\"alert\"\n        >\n          <AlertCircle aria-hidden=\"true\" className=\"size-5 shrink-0 text-destructive\" />\n          <p className=\"font-medium\">{text.error}</p>\n          {errorMessage !== undefined && (\n            <p className=\"min-w-0 text-xs whitespace-pre-line text-destructive wrap-anywhere\">{errorMessage}</p>\n          )}\n          {onRetry && (\n            <button className={cn(quietButtonClass, \"mt-1 border px-3 py-1.5\")} onClick={onRetry} type=\"button\">\n              {text.retry}\n            </button>\n          )}\n        </div>\n      </div>\n    )\n  }\n\n  // A \"ready\" envelope holding zero rows IS the empty state — otherwise a filtered-to-nothing\n  // feed renders a labelled region with nothing in it and a stray footer button.\n  if (status === \"empty\" || groups.length === 0) {\n    return (\n      <div className={rootClass} ref={ref} {...props}>\n        {emptyState ?? (\n          <div className=\"flex flex-col items-center gap-2 rounded-lg border border-dashed p-8 text-center\">\n            <Inbox aria-hidden=\"true\" className=\"size-6 shrink-0 text-muted-foreground\" />\n            <p className=\"text-sm text-muted-foreground\">{text.empty}</p>\n          </div>\n        )}\n      </div>\n    )\n  }\n\n  /* ------------------------------------------------------------------- feed */\n\n  const rovingIndex = Math.min(activeIndex, visibleGroups.length - 1)\n  // -1 is the ARIA value for \"the set size is unknown\", which is the truth while a cursor can\n  // still hand us more rows.\n  const setSize = onLoadMore ? -1 : groups.length\n  /** The single read/unread transition inside the visible window, if it is on screen at all. */\n  const boundaryIndex = visibleGroups.findIndex(\n    (group, index) => index > 0 && group.unread !== visibleGroups[index - 1].unread,\n  )\n\n  /** A separator is drawn wherever the civil day changes — computed up front, never mid-map. */\n  const dayBreaks = visibleGroups.map(\n    (group, index) => index === 0 || group.dayKey !== visibleGroups[index - 1].dayKey,\n  )\n\n  return (\n    <div className={rootClass} ref={ref} {...props}>\n      {unreadCount > 0 && (\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <span className=\"inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground\">\n            <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-full bg-primary\" />\n            {fill(text.newCount, unreadCount)}\n          </span>\n          {onMarkRead && (\n            <button\n              aria-disabled={marked || undefined}\n              className={quietButtonClass}\n              onClick={handleMarkRead}\n              type=\"button\"\n            >\n              {marked ? text.marked : text.markRead}\n            </button>\n          )}\n        </div>\n      )}\n\n      {/* role=\"feed\" owns ARTICLES only. The separators inside it are aria-hidden decoration —\n          every row already carries its own day in the sr-only absolute timestamp and its own\n          \"Unread\" prefix, so nothing is lost and the ownership chain stays legal. */}\n      <div\n        aria-label={label}\n        className=\"flex min-w-0 flex-col gap-1\"\n        onKeyDown={handleFeedKeyDown}\n        ref={feedRef}\n        role=\"feed\"\n      >\n        {visibleGroups.map((group, index) => {\n          const showDay = dayBreaks[index]\n\n          // Element ids are built from the row's position, never from the entry id: an id with a\n          // space in it would silently break `aria-labelledby`, which is a space-separated list.\n          const sentenceId = `${uid}-${index}-what`\n          const timeId = `${uid}-${index}-when`\n          const panelId = `${uid}-${index}-panel`\n          const isOpen = expanded.has(group.id)\n          const folded = group.items.length > 1\n\n          const verbLabel = verbLabels?.[group.verb] ?? humanizeVerb(group.verb)\n          // Up to two people are named; beyond that the tail becomes \"N others\", which is a\n          // phrase, not a person — so it never gets a profile link.\n          const namedActors: SentenceActor[] =\n            collapse === \"actor-verb\" || group.actors.length <= 2\n              ? group.actors\n                  .slice(0, 2)\n                  .map(actor => ({ href: actor.href, key: actor.id, label: nameOf(actor, text) }))\n              : [\n                  { href: group.actors[0].href, key: group.actors[0].id, label: nameOf(group.actors[0], text) },\n                  { key: \"others\", label: fill(text.others, group.actors.length - 1) },\n                ]\n\n          const actorNodes = (\n            <>\n              {joinParts(\n                formatters.locale,\n                namedActors.map(actor => actor.label),\n              ).map((part, partIndex) => {\n                const actor: SentenceActor | null = part.index === null ? null : namedActors[part.index]\n                if (actor === null) {\n                  return <React.Fragment key={`sep-${partIndex}`}>{part.value}</React.Fragment>\n                }\n                return actor.href === undefined ? (\n                  <span className=\"font-medium text-foreground\" key={actor.key}>\n                    {part.value}\n                  </span>\n                ) : (\n                  <a className={linkClass} href={actor.href} key={actor.key}>\n                    {part.value}\n                  </a>\n                )\n              })}\n            </>\n          )\n\n          const target = group.targets[0]\n          const extraTargets = group.targets.length - 1\n          const targetNodes = (\n            <>\n              {target.type !== undefined && `${target.type} `}\n              {target.href !== undefined ? (\n                <a className={cn(linkClass, \"wrap-anywhere\")} href={target.href}>\n                  {target.label}\n                </a>\n              ) : (\n                <span className=\"font-medium text-foreground wrap-anywhere\">{target.label}</span>\n              )}\n              {extraTargets > 0 && ` ${fill(text.moreTargets, extraTargets)}`}\n            </>\n          )\n\n          // `at` is read into a local so TypeScript can narrow it inside the JSX below.\n          const at = group.at\n          const absolute = at === null ? null : formatters.absolute.format(at)\n          // With an unparseable `now` there is no anchor for \"2 hours ago\", so the absolute\n          // stamp becomes the visible label instead of the row losing its time entirely.\n          const stamp = at === null || nowMs === null ? absolute : formatRelative(at - nowMs, formatters.relative)\n          const latestMeta = showMeta ? group.items.find(item => item.meta !== undefined)?.meta : undefined\n\n          return (\n            <React.Fragment key={group.id}>\n              {showDay && (\n                <div aria-hidden=\"true\" className={cn(dividerClass, \"pt-2 text-muted-foreground first:pt-0\")}>\n                  <span className=\"tracking-wide uppercase\">{dayLabelOf(group)}</span>\n                  <span className=\"h-px flex-1 bg-border\" />\n                </div>\n              )}\n\n              {index === boundaryIndex && (\n                <div\n                  aria-hidden=\"true\"\n                  className={cn(dividerClass, group.unread ? \"text-primary\" : \"text-muted-foreground\")}\n                >\n                  <span className=\"h-px flex-1 bg-current opacity-40\" />\n                  <span>{group.unread ? text.newDivider : text.earlierDivider}</span>\n                  <span className=\"h-px flex-1 bg-current opacity-40\" />\n                </div>\n              )}\n\n              <article\n                aria-labelledby={`${sentenceId} ${timeId}`}\n                aria-posinset={index + 1}\n                aria-setsize={setSize}\n                className={cn(\n                  \"flex min-w-0 gap-3 rounded-lg px-2 py-2\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                  group.unread && \"bg-primary/5\",\n                )}\n                onFocus={() => setActiveIndex(index)}\n                ref={node => {\n                  articleRefs.current[index] = node\n                }}\n                tabIndex={index === rovingIndex ? 0 : -1}\n              >\n                <ActorStack actors={group.actors} />\n\n                <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                  <p className=\"min-w-0 leading-snug text-muted-foreground\" id={sentenceId}>\n                    {group.unread && <span className=\"sr-only\">{text.unread}. </span>}\n                    {renderSummary ? (\n                      renderSummary(group, { actors: actorNodes, labels: text, target: targetNodes, verbLabel })\n                    ) : (\n                      <>\n                        {actorNodes} {verbLabel} {targetNodes}\n                      </>\n                    )}\n                  </p>\n\n                  <p className=\"flex min-w-0 flex-wrap items-center gap-x-2 text-xs text-muted-foreground\">\n                    <span className=\"min-w-0 wrap-anywhere\" id={timeId}>\n                      {at === null || stamp === null ? (\n                        // An unparseable instant still renders — the raw string it came in as,\n                        // never \"Invalid Date\" and never a dropped row.\n                        group.items[0].at\n                      ) : (\n                        <>\n                          <time dateTime={new Date(at).toISOString()} title={absolute ?? undefined}>\n                            {stamp}\n                          </time>\n                          {/* `title` alone is announced by almost nothing, so the exact instant —\n                              with its zone, and with the day the aria-hidden separator carries\n                              visually — is repeated for screen readers. */}\n                          {stamp !== absolute && <span className=\"sr-only\"> · {absolute}</span>}\n                        </>\n                      )}\n                    </span>\n                  </p>\n\n                  {latestMeta !== undefined && (\n                    <p className=\"min-w-0 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground wrap-anywhere\">\n                      {latestMeta}\n                    </p>\n                  )}\n\n                  {folded && (\n                    <>\n                      <button\n                        aria-controls={panelId}\n                        aria-expanded={isOpen}\n                        className={cn(quietButtonClass, \"-ml-2 w-fit\")}\n                        onClick={() => toggleGroup(group.id)}\n                        type=\"button\"\n                      >\n                        <ChevronDown\n                          aria-hidden=\"true\"\n                          className={cn(\n                            \"size-3.5 shrink-0 transition-transform motion-reduce:transition-none\",\n                            isOpen && \"rotate-180\",\n                          )}\n                        />\n                        {isOpen ? text.collapse : fill(text.expand, group.items.length)}\n                      </button>\n\n                      {/* Collapsed means display:none, not zero height. A `grid-rows-[0fr]` or\n                          max-height collapse leaves every link inside reachable by Tab, which is\n                          an invisible keyboard trap — and the node stays mounted so\n                          aria-controls always resolves. */}\n                      <ul\n                        className={cn(\"m-0 flex list-none flex-col gap-2 p-0 pt-1\", !isOpen && \"hidden\")}\n                        hidden={!isOpen}\n                        id={panelId}\n                        role=\"list\"\n                      >\n                        {group.items.map(item => {\n                          const itemMs = toMs(item.at)\n                          return (\n                            <li className=\"flex min-w-0 gap-2 text-xs\" key={item.id} role=\"listitem\">\n                              <ActorAvatar actor={item.actor} className=\"size-5 text-[0.5rem]\" />\n                              <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                                <span className=\"min-w-0 text-muted-foreground wrap-anywhere\">\n                                  <span className=\"font-medium text-foreground\">{nameOf(item.actor, text)}</span>\n                                  {itemMs === null ? (\n                                    <> · {item.at}</>\n                                  ) : (\n                                    <>\n                                      {\" · \"}\n                                      <time dateTime={new Date(itemMs).toISOString()}>\n                                        {nowMs === null\n                                          ? formatters.absolute.format(itemMs)\n                                          : formatRelative(itemMs - nowMs, formatters.relative)}\n                                      </time>\n                                    </>\n                                  )}\n                                </span>\n                                {showMeta && item.meta !== undefined && (\n                                  <span className=\"min-w-0 text-muted-foreground wrap-anywhere\">{item.meta}</span>\n                                )}\n                              </div>\n                            </li>\n                          )\n                        })}\n                      </ul>\n                    </>\n                  )}\n                </div>\n\n                {group.unread && (\n                  <span aria-hidden=\"true\" className=\"mt-2.5 size-2 shrink-0 rounded-full bg-primary\" />\n                )}\n              </article>\n            </React.Fragment>\n          )\n        })}\n      </div>\n\n      {(hiddenCount > 0 || onLoadMore !== undefined) && (\n        <button\n          className={cn(quietButtonClass, \"w-full justify-center border py-2\")}\n          onClick={handleReveal}\n          type=\"button\"\n        >\n          <ChevronDown aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n          {hiddenCount > 0 ? fill(text.showMore, hiddenCount) : text.loadMore}\n        </button>\n      )}\n    </div>\n  )\n})\n\nexport default ActivityFeed\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/activity-feed.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Who did it.\n *\n * `id` — not the name — is what decides \"the same person\" when consecutive\n * entries collapse into one row: two people called \"Alex Chen\" must stay two\n * avatars, and a rename between two page loads must not split a group.\n */\nexport const activityFeedActorSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  /** rendered on top of the initials, so a dead URL degrades to initials */\n  avatarUrl: z.string().optional(),\n  /** renders the name as a real link (a profile page); omit it and the name stays plain text */\n  href: z.string().optional(),\n})\nexport type ActivityFeedActor = z.infer<typeof activityFeedActorSchema>\n\n/**\n * What it happened to. `id` is the collapse identity of the thing; when it is\n * absent the label is used instead, so a target that gets renamed mid-stream\n * shows up as two rows rather than one row with a lying title.\n */\nexport const activityFeedTargetSchema = z.object({\n  id: z.string().optional(),\n  label: z.string(),\n  /** domain noun printed before the label: \"issue\", \"document\", \"release\" */\n  type: z.string().optional(),\n  /** renders the label as a real link; omit it and the label stays plain text */\n  href: z.string().optional(),\n})\nexport type ActivityFeedTarget = z.infer<typeof activityFeedTargetSchema>\n\n/**\n * One thing that happened: actor + verb + target + instant.\n *\n * `verb` is the sentence's verb and must be PAST TENSE (\"commented on\",\n * \"merged\", \"deployed\"). English past tense is number-invariant, which is what\n * lets one actor and five actors share a single string: \"Ada commented on X\"\n * and \"Ada and 3 others commented on X\". Machine keys (\"issue.comment\") are\n * accepted too — map them with `verbLabels`, or the component humanises them.\n */\nexport const activityFeedItemSchema = z.object({\n  /** stable identity — React key, expand-state key, and the de-dupe key */\n  id: z.string(),\n  actor: activityFeedActorSchema,\n  verb: z.string(),\n  target: activityFeedTargetSchema,\n  /** ISO 8601 instant; an unparseable value still renders, in a trailing \"Undated\" day */\n  at: z.string(),\n  /** one-line excerpt under the sentence: the comment, the commit subject, the field that moved */\n  meta: z.string().optional(),\n})\nexport type ActivityFeedItem = z.infer<typeof activityFeedItemSchema>\n\n/**\n * Feed-level render state — \"is there a stream to show at all\", independent of\n * any single entry. All four are first-class branches in the component.\n */\nexport const activityFeedStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\nexport type ActivityFeedStatus = z.infer<typeof activityFeedStatusSchema>\n\n/** The envelope a data layer / mock factory hands over; the demo spreads it into the props. */\nexport const activityFeedSchema = z.object({\n  status: activityFeedStatusSchema,\n  items: z.array(activityFeedItemSchema),\n})\nexport type ActivityFeedData = z.infer<typeof activityFeedSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}