{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-feed",
  "title": "Activity feed",
  "description": "A history of what happened, grouped by day: who, what, and a dotted leader to when, on one hairline down the margin. The newest carries the accent; the rest waits behind \"and 4 more\".",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/avatar.json",
    "https://thedirectors.agency/ui/r/collapsible.json",
    "https://thedirectors.agency/ui/r/item.json",
    "https://thedirectors.agency/ui/r/marker.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/activity-feed.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Avatar } from \"@/components/ui/avatar\"\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\"\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from \"@/components/ui/item\"\nimport { Marker } from \"@/components/ui/marker\"\n\ntype ActivityEntry = {\n  id: string\n  /** Who did it. It names the avatar on the line and leads the sentence. Leave it out for what the system did: a dot stands in. */\n  who?: string\n  /** What happened, as the rest of the sentence after the name: \"approved the harbour mark\". */\n  what: React.ReactNode\n  /** A line under it: what they wrote, what changed. */\n  detail?: React.ReactNode\n  /**\n   * When, as local time written out: \"2026-09-30T09:52\". The day and the time are read from it as written, so the\n   * server and the browser always agree.\n   */\n  at: string\n  /** The time as shown at the end of the line. Defaults to the hours and minutes of `at`. */\n  time?: React.ReactNode\n}\n\ntype Variant = \"ledger\" | \"almanac\" | \"lapse\"\n\ntype ActivityFeedProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** Newest first. The feed never reorders them. */\n  entries: ActivityEntry[]\n  /**\n   * ledger: each day begins at a divider, and every line runs on a leader to its time. almanac: each day begins with its\n   * date set as the calendar poster sets it, the day huge, month over year beside it, the weekday at the far end.\n   * lapse: no rules; the silence between two entries grows with the time between them, and a new day is said in it.\n   */\n  variant?: Variant\n  /** How many show before \"and 4 more\". Defaults to all. */\n  initial?: number\n  /** How many at the top are new since the person last looked: the ribbon is laid under them, and it takes the accent. */\n  fresh?: number\n  /** Names for days, by date (\"2026-09-30\": \"Today\"). Others are written out. */\n  days?: Record<string, React.ReactNode>\n  /** For the written-out days. */\n  locale?: string\n  /** Names the feed for assistive technology. */\n  label?: string\n  /** The control that shows the rest. */\n  more?: (count: number) => React.ReactNode\n  /** The same control once the rest is shown. */\n  less?: (count: number) => React.ReactNode\n  /** Lapse only: the words for a pause within a day, given its minutes. */\n  lapse?: (minutes: number) => React.ReactNode\n  /** The ribbon's words. */\n  seen?: React.ReactNode\n  /** What it says when nothing has happened. */\n  empty?: React.ReactNode\n}\n\n// ponytail: `at` is read as written (no time zones): its offset, if any, is ignored for the day and the time.\nconst dayOf = (at: string) => at.slice(0, 10)\nconst ms = (at: string) => Date.parse(/(z|[+-]\\d\\d:?\\d\\d)$/i.test(at) ? at : `${at}Z`)\nconst utc = (date: string) => Date.UTC(+date.slice(0, 4), +date.slice(5, 7) - 1, +date.slice(8, 10))\nconst fmt = (locale: string, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: \"UTC\" })\n\n/** A pause at least this long is given its own silence. */\nconst PAUSE = 60\n\nfunction pauseWords(m: number) {\n  if (m < 60) return `${Math.round(m)} minutes earlier`\n  if (m < 120) return \"An hour earlier\"\n  return `${Math.round(m / 60)} hours earlier`\n}\n\ntype Block =\n  | { kind: \"day\"; date: string; gap: number }\n  | { kind: \"pause\"; gap: number }\n  | { kind: \"seen\" }\n  | { kind: \"run\"; entries: { entry: ActivityEntry; i: number }[] }\n\n/** The feed's lines as blocks: a day's head, a pause, the ribbon, or a run of entries that share one line down the margin. */\nfunction blocks(entries: ActivityEntry[], from: number, to: number, variant: Variant, fresh: number): Block[] {\n  const out: Block[] = []\n  for (let i = from; i < to; i++) {\n    const e = entries[i], prev = entries[i - 1]\n    const gap = prev ? (ms(prev.at) - ms(e.at)) / 60000 : 0\n    if (i > 0 && i === fresh) out.push({ kind: \"seen\" })\n    if (!prev || dayOf(prev.at) !== dayOf(e.at)) out.push({ kind: \"day\", date: dayOf(e.at), gap })\n    else if (variant === \"lapse\" && gap >= PAUSE) out.push({ kind: \"pause\", gap })\n    const last = out.at(-1)\n    if (last?.kind === \"run\") last.entries.push({ entry: e, i })\n    else out.push({ kind: \"run\", entries: [{ entry: e, i }] })\n  }\n  return out\n}\n\n/**\n * A history of what happened, newest first and grouped by day: who (an avatar on a hairline down the margin), what,\n * and a dotted leader to when. The newest carries the accent; \"and 4 more\" holds the rest.\n */\nfunction ActivityFeed({\n  entries,\n  variant = \"ledger\",\n  initial,\n  fresh = 0,\n  days = {},\n  locale = \"en-GB\",\n  label = \"Activity\",\n  more = (n) => `and ${n} more`,\n  less = (n) => `Hide these ${n}`,\n  lapse = pauseWords,\n  seen = \"Where you left off\",\n  empty = \"Nothing has happened yet.\",\n  className,\n  ...props\n}: ActivityFeedProps) {\n  const shown = Math.min(entries.length, initial ?? entries.length)\n  const rest = entries.length - shown\n  const longDay = fmt(locale, { weekday: \"long\", day: \"numeric\", month: \"long\" })\n\n  const day = (b: Extract<Block, { kind: \"day\" }>) => {\n    const named = days[b.date]\n    if (variant === \"almanac\") {\n      const t = utc(b.date)\n      return (\n        <p key={`d${b.date}`} data-slot=\"activity-day\" className=\"ot-feed-day\">\n          <span className=\"ot-feed-date\">{fmt(locale, { day: \"numeric\" }).format(t)}</span>\n          <span className=\"ot-feed-month\">\n            <span>{fmt(locale, { month: \"long\" }).format(t)}</span>\n            <span>{fmt(locale, { year: \"numeric\" }).format(t)}</span>\n          </span>\n          <span className=\"ot-feed-weekday\">{named ?? fmt(locale, { weekday: \"short\" }).format(t)}</span>\n        </p>\n      )\n    }\n    const words = named ?? longDay.format(utc(b.date))\n    return variant === \"lapse\" ? (\n      <Marker key={`d${b.date}`} variant=\"lapse\" minutes={b.gap}>{words}</Marker>\n    ) : (\n      <Marker key={`d${b.date}`} variant=\"divider\">{words}</Marker>\n    )\n  }\n\n  const line = ({ entry: e, i }: { entry: ActivityEntry; i: number }) => (\n    <Item key={e.id} data-slot=\"activity-entry\" className=\"ot-feed-entry\" data-newest={i === 0 && !fresh ? \"\" : undefined}>\n      <ItemMedia className=\"ot-feed-bead\" aria-hidden=\"true\">\n        {e.who ? <Avatar size=\"s\" alt={e.who} /> : <span className=\"ot-feed-dot\" />}\n      </ItemMedia>\n      <ItemContent>\n        <ItemTitle>\n          {e.who ? <b className=\"ot-feed-who\">{e.who}</b> : null}\n          {e.who ? \" \" : null}\n          {e.what}\n        </ItemTitle>\n        {e.detail ? <ItemDescription>{e.detail}</ItemDescription> : null}\n      </ItemContent>\n      <ItemActions>\n        <time dateTime={e.at}>{e.time ?? e.at.slice(11, 16)}</time>\n      </ItemActions>\n    </Item>\n  )\n\n  const render = (list: Block[]) =>\n    list.map((b, k) => {\n      if (b.kind === \"day\") return day(b)\n      if (b.kind === \"pause\") return <Marker key={`p${k}`} variant=\"lapse\" minutes={b.gap}>{lapse(b.gap)}</Marker>\n      if (b.kind === \"seen\") return <Marker key=\"seen\" variant=\"ribbon\">{seen}</Marker>\n      return <ItemGroup key={`r${b.entries[0].entry.id}`} className=\"ot-feed-run\">{b.entries.map(line)}</ItemGroup>\n    })\n\n  return (\n    <section data-slot=\"activity-feed\" data-variant={variant === \"ledger\" ? undefined : variant} aria-label={label} className={cn(\"ot-feed\", className)} {...props}>\n      {entries.length === 0 ? <Marker>{empty}</Marker> : render(blocks(entries, 0, shown, variant, fresh))}\n      {rest > 0 && (\n        <Collapsible className=\"ot-feed-more\">\n          <CollapsibleTrigger openLabel={less(rest)}>{more(rest)}</CollapsibleTrigger>\n          <CollapsibleContent className=\"ot-feed-rest\">{render(blocks(entries, shown, entries.length, variant, fresh))}</CollapsibleContent>\n        </Collapsible>\n      )}\n    </section>\n  )\n}\n\nexport { ActivityFeed, type ActivityEntry, type ActivityFeedProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/activity-feed.css",
      "content": "@layer components {\n  /* ── ot-feed ── a history of what happened, newest first, grouped by day. Each entry is a ledger line (item): who, what,\n     and a dotted leader to when. One hairline runs down the margin through the entries of a stretch, a bead on it for each:\n     the person's ring, or a dot for what the system did. The newest bead carries the accent. */\n  .ot-feed { --bead: 1.75rem; display: grid; gap: var(--ot-space-4); min-width: 0; }\n  .ot-feed-rest { display: grid; gap: var(--ot-space-4); padding-block-start: var(--ot-space-3); }\n  .ot-feed > .ot-marker:not(:first-child), .ot-feed-rest > .ot-marker:not(:first-child) { margin-block-start: var(--ot-space-4); }\n\n  /* The line down the margin replaces the ledger's rules across. */\n  .ot-feed-run.ot-items { border-top: 0; }\n  .ot-feed-entry.ot-item { position: relative; gap: var(--ot-space-4); padding-block: var(--ot-space-3); border-bottom: 0; }\n  .ot-feed-entry::before {\n    content: \"\"; position: absolute; inset-block: 0; inset-inline-start: calc(var(--bead) / 2 - var(--ot-hairline) / 2);\n    width: var(--ot-hairline); background: var(--ot-rule-strong);\n  }\n  /* The bead centres on the entry's first line (a detail runs on under it), so the thread starts and stops there. */\n  .ot-feed-entry:first-child::before { top: calc(var(--ot-space-3) + 0.5lh); }\n  .ot-feed-entry:last-child::before { bottom: calc(100% - var(--ot-space-3) - 0.5lh); }\n  .ot-feed-entry:only-child::before { content: none; }\n  .ot-feed-bead.ot-item-media { position: relative; z-index: 1; align-self: flex-start; justify-content: center; width: var(--bead); height: 1lh; align-items: center; }\n  .ot-feed-dot { width: var(--ot-dot); height: var(--ot-dot); border-radius: 50%; background: var(--ot-ink); box-shadow: 0 0 0 3px var(--ot-paper); }\n  .ot-feed-entry[data-newest] .ot-feed-dot { background: var(--ot-accent); }\n  .ot-feed-entry[data-newest] .ot-avatar { box-shadow: inset 0 0 0 var(--ot-stroke) var(--ot-accent); }\n\n  .ot-feed-entry .ot-item-title { color: var(--ot-graphite); }\n  .ot-feed-who { font-weight: 500; color: var(--ot-ink); }\n  .ot-feed-entry .ot-item-desc { max-width: var(--ot-measure); }\n  .ot-feed-entry .ot-item-end { font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-pencil); white-space: nowrap; transition: color var(--ot-allegro); }\n  .ot-feed-entry:is(:hover, [data-force~=\"hover\"]) .ot-item-end, .ot-feed-entry[data-newest] .ot-item-end, .ot-feed[data-force~=\"hover\"] .ot-item-end { color: var(--ot-ink); }\n  .ot-feed[data-force~=\"hover\"] .ot-item-leader::after { mask-position: 0 0; mask-size: 100% 100%; }\n\n  /* The rest arrives down the line in turn. */\n  .ot-feed-more[open] .ot-feed-entry { animation: ot-arrive var(--ot-moderato) var(--ot-exhale) backwards; animation-delay: calc(sibling-index() * 2 * var(--ot-arpeggio)); }\n\n  /* ── almanac ── \"Less but better\": the day huge and heavy, the month over the year beside it, the weekday at the far end. */\n  .ot-feed-day { display: flex; align-items: flex-end; gap: var(--ot-space-4); margin: 0; color: var(--ot-ink); }\n  .ot-feed > .ot-feed-day:not(:first-child), .ot-feed-rest > .ot-feed-day:not(:first-child) { margin-block-start: var(--ot-space-6); }\n  .ot-feed-date { font-size: var(--ot-f); line-height: 0.74; letter-spacing: -0.04em; font-weight: 700; font-variant-numeric: tabular-nums lining-nums; }\n  .ot-feed-month { display: grid; font-size: var(--ot-pp); line-height: 1.2; letter-spacing: 0.04em; text-transform: uppercase; }\n  .ot-feed-month > :last-child { color: var(--ot-pencil); }\n  .ot-feed-weekday { margin-inline-start: auto; font-size: var(--ot-p); line-height: 1; }\n\n  /* ── lapse ── the time between is given as silence (the marker's lapse), so the thread breaks where it went quiet. */\n  .ot-feed[data-variant=\"lapse\"] { gap: 0; }\n  .ot-feed[data-variant=\"lapse\"] .ot-feed-rest { gap: 0; }\n  .ot-feed[data-variant=\"lapse\"] > .ot-marker, .ot-feed[data-variant=\"lapse\"] .ot-feed-rest > .ot-marker { margin-block-start: 0; }\n\n  @media (forced-colors: active) {\n    .ot-feed-entry::before, .ot-feed-dot { forced-color-adjust: none; background: CanvasText; }\n    .ot-feed-entry[data-newest] .ot-feed-dot { background: Highlight; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/activity-feed.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/activity-feed.css\"": {}
  },
  "meta": {
    "movement": "X",
    "underneath": "native",
    "contract": "ot-feed"
  },
  "type": "registry:ui"
}
