{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-03",
  "title": "Compact side feed",
  "description": "One-line entries for a narrow panel that open in place to show what changed",
  "dependencies": [
    "date-fns",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/chip.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/activity/activity-03.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useId, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { ChevronRightIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport {\n  newestFirst,\n  TASK_HISTORY,\n  type ActivityEvent,\n} from './_parts/activity-model';\nimport { ChangeDetail, sentenceOf } from './_parts/event-parts';\nimport { PersonAvatar } from './_parts/glyphs';\nimport { heightPresenceOf } from './_parts/motion-parts';\nimport { isCurrentUser, personOf } from './_parts/people';\nimport { exactTimeOf, relativeTimeOf } from './_parts/time';\n\nconst SHOWN_COUNT = 6;\n\nfunction shortNameOf(actorId: string) {\n  if (isCurrentUser(actorId)) {\n    return 'You';\n  }\n  return personOf(actorId).firstName;\n}\n\nfunction EventDetail({ event }: { event: ActivityEvent }) {\n  return (\n    <div className='flex flex-col gap-1.5 ps-7 pe-2 pb-2.5'>\n      {event.kind === 'change' && <ChangeDetail change={event.change} />}\n      {event.kind === 'comment' && (\n        <p className='text-[13px] leading-5'>{event.body}</p>\n      )}\n      <time\n        dateTime={event.at}\n        className='text-muted-foreground text-xs tabular-nums'\n      >\n        {exactTimeOf(event.at)}\n      </time>\n    </div>\n  );\n}\n\nfunction CompactRow({\n  event,\n  isOpen,\n  onToggle,\n}: {\n  event: ActivityEvent;\n  isOpen: boolean;\n  onToggle: () => void;\n}) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const detailId = useId();\n  const isExpandable = event.kind !== 'created';\n\n  return (\n    <li className={cn('rounded-lg', isOpen && 'bg-foreground/[0.03]')}>\n      <button\n        type='button'\n        aria-expanded={isOpen}\n        aria-controls={detailId}\n        disabled={!isExpandable}\n        onClick={onToggle}\n        className='hover:bg-foreground/[0.03] focus-visible:ring-ring/25 flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-1.5 text-left outline-none focus-visible:ring-[3px] disabled:cursor-default disabled:hover:bg-transparent'\n      >\n        <PersonAvatar\n          person={personOf(event.actorId)}\n          className='size-4 text-[8px]'\n        />\n        <span className='text-muted-foreground min-w-0 flex-1 truncate text-xs'>\n          <span className='text-foreground font-medium'>\n            {shortNameOf(event.actorId)}\n          </span>{' '}\n          {sentenceOf(event)}\n        </span>\n        <time\n          dateTime={event.at}\n          className='text-muted-foreground shrink-0 text-[11px] tabular-nums'\n        >\n          {relativeTimeOf(event.at)}\n        </time>\n        {isExpandable && (\n          <ChevronRightIcon\n            className={cn(\n              'text-muted-foreground size-3 shrink-0 transition-transform duration-200 ease-(--ease-out-strong)',\n              isOpen && 'rotate-90'\n            )}\n          />\n        )}\n      </button>\n      <div id={detailId}>\n        <AnimatePresence initial={false}>\n          {isOpen && (\n            <motion.div\n              {...heightPresenceOf(shouldReduceMotion)}\n              className='overflow-hidden'\n            >\n              <EventDetail event={event} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </li>\n  );\n}\n\nexport default function Activity03() {\n  const [openId, setOpenId] = useState<string | null>(null);\n  const events = newestFirst(TASK_HISTORY).slice(0, SHOWN_COUNT);\n\n  function toggle(eventId: string) {\n    setOpenId((current) => {\n      if (current === eventId) {\n        return null;\n      }\n      return eventId;\n    });\n  }\n\n  return (\n    <section aria-label='Recent activity' className='w-full max-w-72'>\n      <h3 className='text-muted-foreground px-1.5 text-xs font-medium'>\n        Recent activity\n      </h3>\n      <ul className='mt-1.5 flex flex-col gap-0.5'>\n        {events.map((event) => (\n          <CompactRow\n            key={event.id}\n            event={event}\n            isOpen={openId === event.id}\n            onToggle={() => toggle(event.id)}\n          />\n        ))}\n      </ul>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/activity-03.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/activity-model.ts",
      "content": "import type { PriorityId, StatusId } from './glyphs';\nimport { dayKeyOf, dayLabelOf } from './time';\n\nexport type ActivityChange =\n  | { field: 'status'; from?: StatusId; to?: StatusId }\n  | { field: 'assignee'; from?: string; to?: string }\n  | { field: 'estimate'; from?: number; to?: number }\n  | { field: 'priority'; from?: PriorityId; to?: PriorityId }\n  | { field: 'due'; from?: string; to?: string }\n  | { field: 'label'; from?: string; to?: string };\n\nexport type ActivityField = ActivityChange['field'];\n\nexport type ActivityKind = ActivityField | 'created' | 'comment';\n\ntype EventBase = {\n  id: string;\n  actorId: string;\n  at: string;\n};\n\nexport type ActivityEvent =\n  | (EventBase & { kind: 'created' })\n  | (EventBase & { kind: 'change'; change: ActivityChange })\n  | (EventBase & { kind: 'comment'; body: string });\n\nexport type Label = {\n  id: string;\n  name: string;\n  color: string;\n};\n\nexport const TASK_KEY = 'MOS-214';\n\nexport const TASK_TITLE = 'Export invoices as CSV';\n\nexport const LABELS: Label[] = [\n  { id: 'billing', name: 'Billing', color: 'var(--info)' },\n  { id: 'reports', name: 'Reports', color: 'var(--success)' },\n  { id: 'api', name: 'API', color: 'var(--warning)' },\n];\n\nexport function labelOf(id: string) {\n  const found = LABELS.find((label) => label.id === id);\n  if (found) {\n    return found;\n  }\n  return LABELS[0];\n}\n\nexport const TASK_HISTORY: ActivityEvent[] = [\n  {\n    id: 'e1',\n    kind: 'created',\n    actorId: 'sana',\n    at: '2026-11-03T09:12:00',\n  },\n  {\n    id: 'e2',\n    kind: 'change',\n    actorId: 'sana',\n    at: '2026-11-03T09:13:00',\n    change: { field: 'priority', to: 'high' },\n  },\n  {\n    id: 'e3',\n    kind: 'change',\n    actorId: 'sana',\n    at: '2026-11-03T09:13:00',\n    change: { field: 'label', to: 'billing' },\n  },\n  {\n    id: 'e4',\n    kind: 'change',\n    actorId: 'mei',\n    at: '2026-11-04T10:05:00',\n    change: { field: 'assignee', to: 'leo' },\n  },\n  {\n    id: 'e5',\n    kind: 'change',\n    actorId: 'leo',\n    at: '2026-11-04T10:20:00',\n    change: { field: 'estimate', to: 240 },\n  },\n  {\n    id: 'e6',\n    kind: 'change',\n    actorId: 'leo',\n    at: '2026-11-04T14:02:00',\n    change: { field: 'status', from: 'todo', to: 'in_progress' },\n  },\n  {\n    id: 'e7',\n    kind: 'comment',\n    actorId: 'leo',\n    at: '2026-11-04T16:40:00',\n    body: 'The first pass exports the visible columns only. The totals row comes next.',\n  },\n  {\n    id: 'e8',\n    kind: 'change',\n    actorId: 'leo',\n    at: '2026-11-05T09:31:00',\n    change: { field: 'estimate', from: 240, to: 360 },\n  },\n  {\n    id: 'e9',\n    kind: 'change',\n    actorId: 'leo',\n    at: '2026-11-05T09:32:00',\n    change: { field: 'due', to: '2026-11-07' },\n  },\n  {\n    id: 'e10',\n    kind: 'change',\n    actorId: 'leo',\n    at: '2026-11-05T10:18:00',\n    change: { field: 'status', from: 'in_progress', to: 'in_review' },\n  },\n  {\n    id: 'e11',\n    kind: 'change',\n    actorId: 'amara',\n    at: '2026-11-05T10:24:00',\n    change: { field: 'assignee', to: 'tomas' },\n  },\n  {\n    id: 'e12',\n    kind: 'comment',\n    actorId: 'tomas',\n    at: '2026-11-05T10:52:00',\n    body: 'Reviewing now. Dates come out in the account time zone, as the ticket asks.',\n  },\n];\n\nexport function kindOf(event: ActivityEvent): ActivityKind {\n  if (event.kind === 'change') {\n    return event.change.field;\n  }\n  return event.kind;\n}\n\nexport type ActivityDay = {\n  key: string;\n  label: string;\n  events: ActivityEvent[];\n};\n\nexport function daysOf(events: ActivityEvent[]) {\n  const days: ActivityDay[] = [];\n  for (const event of events) {\n    const key = dayKeyOf(event.at);\n    const last = days.at(-1);\n    const isSameDay = last?.key === key;\n    if (isSameDay) {\n      last.events.push(event);\n      continue;\n    }\n    days.push({ key, label: dayLabelOf(event.at), events: [event] });\n  }\n  return days;\n}\n\nexport function newestFirst(events: ActivityEvent[]) {\n  return [...events].sort((first, second) => second.at.localeCompare(first.at));\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/activity-model.ts"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/event-parts.tsx",
      "content": "import {\n  ArrowRightIcon,\n  CalendarIcon,\n  CircleDashedIcon,\n  ClockIcon,\n  HashIcon,\n  MessageCircleIcon,\n  PlusIcon,\n  UserIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Chip } from '@/registry/ndeko/ui/chip';\n\nimport {\n  labelOf,\n  type ActivityChange,\n  type ActivityEvent,\n  type ActivityField,\n  type ActivityKind,\n} from './activity-model';\nimport {\n  PersonAvatar,\n  PRIORITY_NAME,\n  PriorityGlyph,\n  STATUS_NAME,\n  StatusGlyph,\n} from './glyphs';\nimport { actorNameOf, personOf } from './people';\nimport { durationLabelOf, shortDateOf } from './time';\n\nconst KIND_ICON: Record<Exclude<ActivityKind, 'priority'>, Icon> = {\n  created: PlusIcon,\n  comment: MessageCircleIcon,\n  status: CircleDashedIcon,\n  assignee: UserIcon,\n  estimate: ClockIcon,\n  due: CalendarIcon,\n  label: HashIcon,\n};\n\n/** The kind of an event on a small key cap, the way the property draws it in a menu. */\nexport function EventGlyph({\n  kind,\n  className,\n}: {\n  kind: ActivityKind;\n  className?: string;\n}) {\n  const frame = cn(\n    'keycap-3d text-muted-foreground relative flex size-6 shrink-0 items-center justify-center rounded-full [&_svg]:size-3',\n    className\n  );\n  if (kind === 'priority') {\n    return (\n      <span className={frame}>\n        <PriorityGlyph priority='high' className='text-muted-foreground' />\n      </span>\n    );\n  }\n  const Glyph = KIND_ICON[kind];\n  return (\n    <span className={frame}>\n      <Glyph />\n    </span>\n  );\n}\n\ntype Wording = {\n  changed: string;\n  set: string;\n  cleared: string;\n};\n\nconst WORDING: Record<ActivityField, Wording> = {\n  status: {\n    changed: 'changed the status',\n    set: 'set the status',\n    cleared: 'cleared the status',\n  },\n  assignee: {\n    changed: 'reassigned the task',\n    set: 'assigned',\n    cleared: 'unassigned',\n  },\n  estimate: {\n    changed: 'changed the estimate',\n    set: 'set the estimate',\n    cleared: 'cleared the estimate',\n  },\n  priority: {\n    changed: 'changed the priority',\n    set: 'set the priority',\n    cleared: 'cleared the priority',\n  },\n  due: {\n    changed: 'moved the due date',\n    set: 'set the due date',\n    cleared: 'removed the due date',\n  },\n  label: {\n    changed: 'swapped the label',\n    set: 'added the label',\n    cleared: 'removed the label',\n  },\n};\n\nexport function verbOf(change: ActivityChange) {\n  const wording = WORDING[change.field];\n  const hasFrom = change.from !== undefined;\n  const hasTo = change.to !== undefined;\n  const isReplacement = hasFrom && hasTo;\n  if (isReplacement) {\n    return wording.changed;\n  }\n  if (hasTo) {\n    return wording.set;\n  }\n  return wording.cleared;\n}\n\nexport function sentenceOf(event: ActivityEvent) {\n  if (event.kind === 'created') {\n    return 'created the task';\n  }\n  if (event.kind === 'comment') {\n    return 'commented';\n  }\n  return verbOf(event.change);\n}\n\ntype Side = 'from' | 'to';\n\nconst VALUE = 'inline-flex min-w-0 items-center gap-1 align-middle';\n\nfunction valueNodeOf(change: ActivityChange, side: Side) {\n  if (change.field === 'status') {\n    const status = change[side];\n    if (!status) {\n      return null;\n    }\n    return (\n      <>\n        <StatusGlyph status={status} />\n        {STATUS_NAME[status]}\n      </>\n    );\n  }\n  if (change.field === 'priority') {\n    const priority = change[side];\n    if (!priority) {\n      return null;\n    }\n    return (\n      <>\n        <PriorityGlyph priority={priority} />\n        {PRIORITY_NAME[priority]}\n      </>\n    );\n  }\n  if (change.field === 'assignee') {\n    const personId = change[side];\n    if (!personId) {\n      return null;\n    }\n    const person = personOf(personId);\n    return (\n      <>\n        <PersonAvatar person={person} className='size-4 text-[8px]' />\n        {person.firstName}\n      </>\n    );\n  }\n  if (change.field === 'estimate') {\n    const minutes = change[side];\n    if (minutes === undefined) {\n      return null;\n    }\n    return <span className='tabular-nums'>{durationLabelOf(minutes)}</span>;\n  }\n  if (change.field === 'due') {\n    const day = change[side];\n    if (!day) {\n      return null;\n    }\n    return <span className='tabular-nums'>{shortDateOf(day)}</span>;\n  }\n  const labelId = change[side];\n  if (!labelId) {\n    return null;\n  }\n  const label = labelOf(labelId);\n  return <Chip color={label.color}>{label.name}</Chip>;\n}\n\n/** The old value muted, an arrow, then the new value drawn the way its property draws it. */\nexport function ChangeDetail({\n  change,\n  className,\n}: {\n  change: ActivityChange;\n  className?: string;\n}) {\n  const from = valueNodeOf(change, 'from');\n  const to = valueNodeOf(change, 'to');\n  const isReplacement = Boolean(from) && Boolean(to);\n  const isRemoval = Boolean(from) && !to;\n\n  return (\n    <span\n      className={cn(\n        'inline-flex max-w-full flex-wrap items-center gap-x-1.5 gap-y-1 text-[13px]',\n        className\n      )}\n    >\n      {isReplacement && (\n        <>\n          <span className={cn(VALUE, 'text-muted-foreground')}>{from}</span>\n          <ArrowRightIcon className='text-muted-foreground/70 size-3' />\n          <span className='sr-only'>to</span>\n        </>\n      )}\n      {isRemoval && (\n        <span\n          className={cn(\n            VALUE,\n            'text-muted-foreground decoration-foreground/30 line-through'\n          )}\n        >\n          {from}\n        </span>\n      )}\n      {to && <span className={cn(VALUE, 'text-foreground')}>{to}</span>}\n    </span>\n  );\n}\n\nexport function ActorName({ actorId }: { actorId: string }) {\n  return (\n    <span className='text-foreground font-medium'>{actorNameOf(actorId)}</span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/event-parts.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/glyphs.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport type { Person } from './people';\n\nexport type StatusId =\n  'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done';\n\nexport type PriorityId = 'urgent' | 'high' | 'medium' | 'low';\n\nexport const STATUS_NAME: Record<StatusId, string> = {\n  backlog: 'Backlog',\n  todo: 'Todo',\n  in_progress: 'In progress',\n  in_review: 'In review',\n  done: 'Done',\n};\n\nconst STATUS_COLOR: Record<StatusId, string> = {\n  backlog: 'var(--muted-foreground)',\n  todo: 'var(--muted-foreground)',\n  in_progress: 'var(--warning)',\n  in_review: 'var(--primary)',\n  done: 'var(--success)',\n};\n\nexport const PRIORITY_NAME: Record<PriorityId, string> = {\n  urgent: 'Urgent',\n  high: 'High',\n  medium: 'Medium',\n  low: 'Low',\n};\n\nconst PIE_RADIUS = 2;\n\nconst PIE_CIRCUMFERENCE = 2 * Math.PI * PIE_RADIUS;\n\nfunction StartedShape({ share }: { share: number }) {\n  const filled = share * PIE_CIRCUMFERENCE;\n  return (\n    <>\n      <circle cx='7' cy='7' r='6' stroke='currentColor' strokeWidth='1.5' />\n      <circle\n        cx='7'\n        cy='7'\n        r={PIE_RADIUS}\n        stroke='currentColor'\n        strokeWidth={PIE_RADIUS * 2}\n        strokeDasharray={`${filled} ${PIE_CIRCUMFERENCE}`}\n        transform='rotate(-90 7 7)'\n      />\n    </>\n  );\n}\n\nfunction statusShapeOf(status: StatusId) {\n  if (status === 'backlog') {\n    return (\n      <circle\n        cx='7'\n        cy='7'\n        r='6'\n        stroke='currentColor'\n        strokeWidth='1.5'\n        strokeDasharray='1.6 1.9'\n      />\n    );\n  }\n  if (status === 'todo') {\n    return (\n      <circle cx='7' cy='7' r='6' stroke='currentColor' strokeWidth='1.5' />\n    );\n  }\n  if (status === 'in_progress') {\n    return <StartedShape share={0.5} />;\n  }\n  if (status === 'in_review') {\n    return <StartedShape share={0.75} />;\n  }\n  return (\n    <>\n      <circle cx='7' cy='7' r='7' fill='currentColor' />\n      <path\n        d='M4.4 7.2 6.2 9l3.4-3.8'\n        stroke='var(--card)'\n        strokeWidth='1.6'\n        strokeLinecap='round'\n        strokeLinejoin='round'\n      />\n    </>\n  );\n}\n\nexport function StatusGlyph({\n  status,\n  className,\n}: {\n  status: StatusId;\n  className?: string;\n}) {\n  const glyphStyle = { color: STATUS_COLOR[status] } as React.CSSProperties;\n  return (\n    <svg\n      viewBox='0 0 14 14'\n      fill='none'\n      aria-hidden='true'\n      style={glyphStyle}\n      className={cn('size-3.5 shrink-0', className)}\n    >\n      {statusShapeOf(status)}\n    </svg>\n  );\n}\n\nconst BAR_HEIGHTS = [4, 7, 10];\n\nconst FILLED_BARS: Record<PriorityId, number> = {\n  urgent: 3,\n  high: 3,\n  medium: 2,\n  low: 1,\n};\n\nfunction barOpacityOf(index: number, filledCount: number) {\n  if (index < filledCount) {\n    return 0.8;\n  }\n  return 0.2;\n}\n\nexport function PriorityGlyph({\n  priority,\n  className,\n}: {\n  priority: PriorityId;\n  className?: string;\n}) {\n  const isUrgent = priority === 'urgent';\n  const filledCount = FILLED_BARS[priority];\n  return (\n    <svg\n      viewBox='0 0 14 14'\n      aria-hidden='true'\n      className={cn(\n        'text-foreground size-3.5 shrink-0',\n        isUrgent && 'text-destructive-ink',\n        className\n      )}\n    >\n      {BAR_HEIGHTS.map((height, index) => (\n        <rect\n          key={height}\n          x={1.5 + index * 4.25}\n          y={12 - height}\n          width='2.5'\n          height={height}\n          rx='0.75'\n          fill='currentColor'\n          fillOpacity={barOpacityOf(index, filledCount)}\n        />\n      ))}\n    </svg>\n  );\n}\n\nexport function PersonAvatar({\n  person,\n  className,\n}: {\n  person: Person;\n  className?: string;\n}) {\n  return (\n    <Avatar\n      className={cn('size-6 text-[10px]', className)}\n      seed={person.name}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/glyphs.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/motion-parts.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type MotionProps,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nexport const EASE_OUT_STRONG: [number, number, number, number] = [\n  0.23, 1, 0.32, 1,\n];\n\nexport const ENTER: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nexport const EXIT: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nexport type PresenceProps = Pick<\n  MotionProps,\n  'initial' | 'animate' | 'exit' | 'transition'\n>;\n\nconst HEIGHT_PRESENCE: PresenceProps = {\n  initial: { height: 0, opacity: 0 },\n  animate: { height: 'auto', opacity: 1 },\n  exit: { height: 0, opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\nconst FADE_PRESENCE: PresenceProps = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\n/** Rows that arrive or leave a list: their height opens and closes so the rows after them slide. */\nexport function heightPresenceOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_PRESENCE;\n  }\n  return HEIGHT_PRESENCE;\n}\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nconst SWAP_VARIANTS: Variants = {\n  hidden: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  shown: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1 },\n};\n\nfunction swapVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\ntype SwapProps = {\n  swapKey: string;\n  children: React.ReactNode;\n  className?: string;\n};\n\n/** An icon or a short label that becomes another one in place. */\nexport function Swap({ swapKey, children, className }: SwapProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  return (\n    <span className={cn('relative inline-flex', className)}>\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span\n          key={swapKey}\n          variants={swapVariantsOf(shouldReduceMotion)}\n          initial='hidden'\n          animate='shown'\n          exit='hidden'\n          transition={SWAP_TRANSITION}\n          className='inline-flex'\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst COUNT_SLIDE = 8;\n\nconst COUNT_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT_STRONG };\n\nconst COUNT_VARIANTS: Variants = {\n  enter: (offset: number) => ({ y: offset, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  leave: (offset: number) => ({ y: -offset, opacity: 0 }),\n};\n\nfunction directionOf(previous: number, next: number) {\n  if (next < previous) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction offsetOf(direction: number, shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 0;\n  }\n  return direction * COUNT_SLIDE;\n}\n\ntype RollingCountProps = {\n  value: number;\n  /** Writes the value, such as a duration; the digits still roll on the number. */\n  format?: (value: number) => string;\n  className?: string;\n};\n\nfunction plainNumber(value: number) {\n  return String(value);\n}\n\n/** A figure the person just changed: the new digits slide in from the side of the change. */\nexport function RollingCount({\n  value,\n  format = plainNumber,\n  className,\n}: RollingCountProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== value) {\n    setDirection(directionOf(previous, value));\n    setPrevious(value);\n  }\n\n  const offset = offsetOf(direction, shouldReduceMotion);\n\n  return (\n    <span\n      className={cn(\n        'relative inline-flex overflow-hidden tabular-nums',\n        className\n      )}\n    >\n      <AnimatePresence mode='popLayout' initial={false} custom={offset}>\n        <motion.span\n          key={value}\n          custom={offset}\n          variants={COUNT_VARIANTS}\n          initial='enter'\n          animate='center'\n          exit='leave'\n          transition={COUNT_TRANSITION}\n          className='inline-block'\n        >\n          {format(value)}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/motion-parts.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n  firstName: string;\n};\n\nexport const CURRENT_USER_ID = 'amara';\n\nexport const PEOPLE: Person[] = [\n  {\n    id: 'amara',\n    name: 'Amara Okafor',\n    firstName: 'Amara',\n  },\n  {\n    id: 'leo',\n    name: 'Léo Martin',\n    firstName: 'Léo',\n  },\n  {\n    id: 'sana',\n    name: 'Sana Iqbal',\n    firstName: 'Sana',\n  },\n  {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n    firstName: 'Tomás',\n  },\n  {\n    id: 'mei',\n    name: 'Mei Lin',\n    firstName: 'Mei',\n  },\n  {\n    id: 'jonah',\n    name: 'Jonah Brandt',\n    firstName: 'Jonah',\n  },\n];\n\nexport function personOf(id: string) {\n  const found = PEOPLE.find((person) => person.id === id);\n  if (found) {\n    return found;\n  }\n  return PEOPLE[0];\n}\n\nexport function isCurrentUser(id: string) {\n  return id === CURRENT_USER_ID;\n}\n\nexport function actorNameOf(id: string) {\n  if (isCurrentUser(id)) {\n    return 'You';\n  }\n  return personOf(id).name;\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/time.ts",
      "content": "import {\n  differenceInCalendarDays,\n  differenceInMinutes,\n  format,\n  parseISO,\n} from 'date-fns';\n\n/** The previews read every time against one fixed moment, so the server and the browser print the same labels. */\nexport const NOW = '2026-11-05T11:00:00';\n\nconst MINUTES_PER_HOUR = 60;\n\nfunction daysBeforeNow(iso: string) {\n  return differenceInCalendarDays(parseISO(NOW), parseISO(iso));\n}\n\nexport function relativeTimeOf(iso: string) {\n  const minutes = differenceInMinutes(parseISO(NOW), parseISO(iso));\n  if (minutes < 1) {\n    return 'Just now';\n  }\n  if (minutes < MINUTES_PER_HOUR) {\n    return `${minutes}m ago`;\n  }\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n    return `${hours}h ago`;\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function exactTimeOf(iso: string) {\n  return format(parseISO(iso), \"EEE, MMM d 'at' HH:mm\");\n}\n\nexport function clockOf(iso: string) {\n  return format(parseISO(iso), 'HH:mm');\n}\n\nexport function dayKeyOf(iso: string) {\n  return iso.slice(0, 10);\n}\n\nexport function dayLabelOf(iso: string) {\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    return 'Today';\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'EEEE, MMM d');\n}\n\nexport function shortDateOf(iso: string) {\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function durationLabelOf(minutes: number) {\n  const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n  const rest = minutes % MINUTES_PER_HOUR;\n  if (hours === 0) {\n    return `${rest}m`;\n  }\n  if (rest === 0) {\n    return `${hours}h`;\n  }\n  return `${hours}h ${rest}m`;\n}\n\nexport function countLabel(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return `${count} ${singular}`;\n  }\n  return `${count} ${plural}`;\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/time.ts"
    }
  ],
  "categories": [
    "components",
    "activity"
  ],
  "type": "registry:component"
}