{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-07",
  "title": "Mentions with quotes",
  "description": "Mentions and replies with the quoted comment cut at two lines, opened in place when it runs longer",
  "dependencies": [
    "date-fns",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/avatar.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-07.tsx",
      "content": "'use client';\n\nimport { useId, useLayoutEffect, useRef, useState } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\n\nimport { PersonAvatar } from './_parts/glyphs';\nimport { Swap } from './_parts/motion-parts';\nimport { actorNameOf, personOf } from './_parts/people';\nimport { relativeTimeOf } from './_parts/time';\n\ntype Segment = {\n  text: string;\n  isMention?: boolean;\n};\n\ntype QuotedEvent = {\n  id: string;\n  actorId: string;\n  at: string;\n  action: string;\n  taskKey: string;\n  quote: Segment[];\n};\n\nconst EVENTS: QuotedEvent[] = [\n  {\n    id: 'q1',\n    actorId: 'sana',\n    at: '2026-11-05T10:46:00',\n    action: 'mentioned you in a comment on',\n    taskKey: 'MOS-217',\n    quote: [\n      { text: '@Amara', isMention: true },\n      {\n        text: ' the quarterly report filters now keep their values when you switch between teams. The saved views still forget the date range though, so I left that part for a follow-up task. Could you check whether finance needs the range saved before Friday, or whether the follow-up can wait for the next cycle?',\n      },\n    ],\n  },\n  {\n    id: 'q2',\n    actorId: 'leo',\n    at: '2026-11-05T09:58:00',\n    action: 'replied to your comment on',\n    taskKey: 'MOS-214',\n    quote: [\n      {\n        text: 'Agreed, the totals row goes last. I will ship the column picker today and keep the totals for tomorrow morning.',\n      },\n    ],\n  },\n  {\n    id: 'q3',\n    actorId: 'tomas',\n    at: '2026-11-04T16:12:00',\n    action: 'mentioned you in a comment on',\n    taskKey: 'MOS-209',\n    quote: [\n      { text: 'The refund copy reads well. ' },\n      { text: '@Amara', isMention: true },\n      {\n        text: ', can the second paragraph lose the legal wording? Support says people stop reading there, and the same terms already sit behind the link at the foot of the page, so nothing is lost if we cut it.',\n      },\n    ],\n  },\n];\n\nfunction QuoteText({\n  segments,\n  isOpen,\n  textId,\n  onClampChange,\n}: {\n  segments: Segment[];\n  isOpen: boolean;\n  textId: string;\n  onClampChange: (isClamped: boolean) => void;\n}) {\n  const textRef = useRef<HTMLParagraphElement>(null);\n\n  useLayoutEffect(() => {\n    const node = textRef.current;\n    if (!node) {\n      return;\n    }\n    if (isOpen) {\n      return;\n    }\n    const observer = new ResizeObserver(() => {\n      const overflows = node.scrollHeight > node.clientHeight + 1;\n      onClampChange(overflows);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [isOpen, onClampChange]);\n\n  return (\n    <p\n      ref={textRef}\n      id={textId}\n      className={cn(\n        'text-[13px] leading-5 [overflow-wrap:anywhere]',\n        !isOpen && 'line-clamp-2'\n      )}\n    >\n      {segments.map((segment, index) => {\n        const key = `${index}-${segment.text}`;\n        if (segment.isMention) {\n          return (\n            <span\n              key={key}\n              className='bg-primary/10 text-primary-ink dark:bg-primary/20 rounded-[4px] px-0.5 font-medium'\n            >\n              {segment.text}\n            </span>\n          );\n        }\n        return <span key={key}>{segment.text}</span>;\n      })}\n    </p>\n  );\n}\n\nfunction toggleLabelOf(isOpen: boolean) {\n  if (isOpen) {\n    return 'Show less';\n  }\n  return 'Show more';\n}\n\nfunction QuotedEntry({ event }: { event: QuotedEvent }) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [isClamped, setIsClamped] = useState(false);\n  const textId = useId();\n  const showsToggle = isClamped || isOpen;\n  const label = toggleLabelOf(isOpen);\n\n  return (\n    <li className='flex gap-3 py-2.5'>\n      <PersonAvatar\n        person={personOf(event.actorId)}\n        className='size-7 text-[10px]'\n      />\n      <div className='min-w-0 flex-1'>\n        <div className='flex items-start justify-between gap-3'>\n          <p className='text-muted-foreground min-w-0 text-[13px] leading-5'>\n            <span className='text-foreground font-medium'>\n              {actorNameOf(event.actorId)}\n            </span>{' '}\n            {event.action}{' '}\n            <span className='text-foreground tabular-nums'>\n              {event.taskKey}\n            </span>\n          </p>\n          <time\n            dateTime={event.at}\n            className='text-muted-foreground shrink-0 pt-0.5 text-xs tabular-nums'\n          >\n            {relativeTimeOf(event.at)}\n          </time>\n        </div>\n        <blockquote className='bg-foreground/[0.03] mt-1.5 rounded-lg px-3 py-2'>\n          <AnimatedSize>\n            <QuoteText\n              segments={event.quote}\n              isOpen={isOpen}\n              textId={textId}\n              onClampChange={setIsClamped}\n            />\n          </AnimatedSize>\n          {showsToggle && (\n            <button\n              type='button'\n              aria-expanded={isOpen}\n              aria-controls={textId}\n              onClick={() => setIsOpen((current) => !current)}\n              className='text-primary-ink focus-visible:ring-ring/25 mt-1 cursor-pointer rounded-sm text-xs font-medium outline-none hover:underline focus-visible:ring-[3px]'\n            >\n              <AnimatedSize as='span' axis='width'>\n                <Swap swapKey={label}>{label}</Swap>\n              </AnimatedSize>\n            </button>\n          )}\n        </blockquote>\n      </div>\n    </li>\n  );\n}\n\nexport default function Activity07() {\n  return (\n    <section aria-label='Mentions and replies' className='w-full max-w-md'>\n      <h3 className='text-sm font-medium'>Mentions and replies</h3>\n      <ul className='mt-1'>\n        {EVENTS.map((event) => (\n          <QuotedEntry key={event.id} event={event} />\n        ))}\n      </ul>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/activity-07.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"
}