{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-27",
  "title": "Task activity thread",
  "description": "The latest change on a task under its chip, with the earlier updates folded below.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/button.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/notification/notification-27.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 {\n  ArrowRightIcon,\n  CheckIcon,\n  ChevronDownIcon,\n  CircleAlertIcon,\n  EclipseIcon,\n  EyeIcon,\n  MailIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\n\nimport { rowCollapseOf } from './_parts/collapse';\nimport { PEOPLE, type Person } from './_parts/people';\nimport { SenderAvatar, type NotificationKind } from './_parts/sender-avatar';\nimport { Swap } from './_parts/swap';\nimport { UnreadDot } from './_parts/unread-dot';\n\ntype StatusMeta = { label: string; color: string; glyph: Icon };\n\nconst IN_PROGRESS: StatusMeta = {\n  label: 'In progress',\n  color: 'var(--warning)',\n  glyph: EclipseIcon,\n};\n\nconst IN_REVIEW: StatusMeta = {\n  label: 'In review',\n  color: 'var(--primary)',\n  glyph: EyeIcon,\n};\n\ntype EarlierUpdate = {\n  id: string;\n  kind: NotificationKind;\n  person: Person;\n  text: string;\n  quote?: string;\n  age: string;\n};\n\nconst EARLIER_UPDATES: EarlierUpdate[] = [\n  {\n    id: 'comment',\n    kind: 'comment',\n    person: PEOPLE.mei,\n    text: 'commented',\n    quote:\n      'The empty week only shows on Safari when the month starts on a Sunday',\n    age: 'Yesterday',\n  },\n  {\n    id: 'assigned',\n    kind: 'assigned',\n    person: PEOPLE.sana,\n    text: 'assigned it to you',\n    age: 'Mon',\n  },\n];\n\nfunction earlierLabelOf(count: number, isOpen: boolean) {\n  if (isOpen) {\n    return 'Hide earlier updates';\n  }\n  if (count === 1) {\n    return 'Show 1 earlier update';\n  }\n  return `Show ${count} earlier updates`;\n}\n\nfunction readLabelOf(isUnread: boolean) {\n  if (isUnread) {\n    return 'Mark as read';\n  }\n  return 'Mark as unread';\n}\n\nfunction StatusChip({ status }: { status: StatusMeta }) {\n  const Glyph = status.glyph;\n  const chipStyle = { '--chip': status.color } as React.CSSProperties;\n\n  return (\n    <span\n      style={chipStyle}\n      className='chip-3d inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-xs font-medium whitespace-nowrap'\n    >\n      <Glyph strokeWidth={2.25} className='size-3' />\n      {status.label}\n    </span>\n  );\n}\n\nfunction TaskChip() {\n  return (\n    <a\n      href='#'\n      className='hover:bg-foreground/[0.04] focus-visible:ring-ring/25 -mx-1.5 flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-[3px]'\n    >\n      <span\n        role='img'\n        aria-label='Bug'\n        className='gem-plate inline-flex size-4 shrink-0 items-center justify-center rounded-[5px] [--gem:var(--destructive)]'\n      >\n        <CircleAlertIcon strokeWidth={2.5} className='size-2.5' />\n      </span>\n      <span className='text-muted-foreground shrink-0 text-xs tabular-nums'>\n        FRN-148\n      </span>\n      <span className='truncate text-[13px] font-medium'>\n        Booking calendar skips a week\n      </span>\n    </a>\n  );\n}\n\nfunction EarlierRow({ update }: { update: EarlierUpdate }) {\n  return (\n    <li className='flex items-start gap-3'>\n      <SenderAvatar person={update.person} kind={update.kind} />\n      <div className='flex min-w-0 flex-1 flex-col gap-1.5'>\n        <p className='text-muted-foreground text-[13px] leading-5'>\n          <span className='text-foreground font-medium'>\n            {update.person.name}\n          </span>{' '}\n          {update.text}\n          <span className='text-xs tabular-nums'> · {update.age}</span>\n        </p>\n        {update.quote && (\n          <blockquote className='bg-foreground/[0.025] hairline-ring ring-border rounded-lg px-3 py-2 text-[13px] leading-5'>\n            {update.quote}\n          </blockquote>\n        )}\n      </div>\n    </li>\n  );\n}\n\nexport default function Notification27() {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const earlierId = useId();\n  const [isUnread, setIsUnread] = useState(true);\n  const [isEarlierOpen, setIsEarlierOpen] = useState(false);\n  const readLabel = readLabelOf(isUnread);\n\n  function toggleEarlier() {\n    setIsEarlierOpen((current) => !current);\n  }\n\n  function toggleRead() {\n    setIsUnread((current) => !current);\n  }\n\n  return (\n    <article\n      aria-label='Updates on FRN-148'\n      className='bg-card hairline-ring ring-border relative flex w-full max-w-[26rem] flex-col gap-3 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      <div className='flex items-center gap-2'>\n        <TaskChip />\n        <span className='ms-auto flex shrink-0 items-center gap-2'>\n          <span className='text-muted-foreground text-xs tabular-nums'>4m</span>\n          <UnreadDot isUnread={isUnread} />\n        </span>\n      </div>\n      <div className='flex items-start gap-3'>\n        <SenderAvatar person={PEOPLE.leo} kind='status' />\n        <div className='flex min-w-0 flex-col gap-1.5'>\n          <p className='text-muted-foreground text-[13px] leading-5'>\n            <span\n              className={cn(\n                'font-medium transition-colors duration-200',\n                isUnread && 'text-foreground'\n              )}\n            >\n              Léo Martin\n            </span>{' '}\n            changed the status\n          </p>\n          <p className='flex flex-wrap items-center gap-1.5'>\n            <StatusChip status={IN_PROGRESS} />\n            <ArrowRightIcon className='text-muted-foreground size-3.5' />\n            <span className='sr-only'>to</span>\n            <StatusChip status={IN_REVIEW} />\n          </p>\n        </div>\n      </div>\n      <AnimatePresence initial={false}>\n        {isEarlierOpen && (\n          <motion.div\n            key='earlier'\n            id={earlierId}\n            variants={rowCollapseOf(shouldReduceMotion)}\n            initial='hidden'\n            animate='shown'\n            exit='hidden'\n            className='-mx-1 -mb-3 overflow-hidden px-1'\n          >\n            <ul\n              aria-label='Earlier updates'\n              className='flex flex-col gap-3 pb-3'\n            >\n              {EARLIER_UPDATES.map((update) => (\n                <EarlierRow key={update.id} update={update} />\n              ))}\n            </ul>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <div className='-mx-1.5 flex items-center justify-between gap-2'>\n        <Button\n          variant='ghost'\n          size='sm'\n          aria-expanded={isEarlierOpen}\n          aria-controls={earlierId}\n          onClick={toggleEarlier}\n          className='text-muted-foreground'\n        >\n          <ChevronDownIcon\n            className={cn(\n              'transition-transform duration-200 ease-(--ease-out-strong)',\n              isEarlierOpen && 'rotate-180'\n            )}\n          />\n          {earlierLabelOf(EARLIER_UPDATES.length, isEarlierOpen)}\n        </Button>\n        <Button\n          variant='ghost'\n          size='sm'\n          onClick={toggleRead}\n          className='text-muted-foreground'\n        >\n          <Swap swapKey={String(isUnread)}>\n            {isUnread && <CheckIcon className='size-4' />}\n            {!isUnread && <MailIcon className='size-4' />}\n          </Swap>\n          {readLabel}\n        </Button>\n      </div>\n    </article>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/notification-27.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/collapse.ts",
      "content": "import type { Transition, Variants } from 'motion/react';\n\nconst EASE_OUT_STRONG: [number, number, number, number] = [0.23, 1, 0.32, 1];\n\n/**\n * The spring AnimatedSize resizes with. It starts from rest, so the fade leads and the freed space\n * closes after it at the pace the surrounding container follows, instead of snapping shut.\n */\nconst SIZE_SPRING: Transition = { type: 'spring', bounce: 0, duration: 0.32 };\n\nconst ARRIVE: Transition = {\n  ...SIZE_SPRING,\n  opacity: { duration: 0.2, ease: EASE_OUT_STRONG },\n};\n\nconst LEAVE: Transition = {\n  ...SIZE_SPRING,\n  opacity: { duration: 0.12, ease: EASE_OUT_STRONG },\n};\n\nconst ARRIVE_FADE: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nconst LEAVE_FADE: Transition = { duration: 0.12, ease: EASE_OUT_STRONG };\n\n/** A row whose spacing sits inside it: only its height and opacity change. */\nconst ROW_VARIANTS: Variants = {\n  shown: { height: 'auto', opacity: 1, transition: ARRIVE },\n  hidden: { height: 0, opacity: 0, transition: LEAVE },\n};\n\n/** A padded surface dismissed for good: its padding closes with its height. */\nconst SURFACE_VARIANTS: Variants = {\n  shown: { height: 'auto', opacity: 1, transition: ARRIVE },\n  hidden: {\n    height: 0,\n    opacity: 0,\n    paddingTop: 0,\n    paddingBottom: 0,\n    transition: LEAVE,\n  },\n};\n\nconst FADE_VARIANTS: Variants = {\n  shown: { opacity: 1, transition: ARRIVE_FADE },\n  hidden: { opacity: 0, transition: LEAVE_FADE },\n};\n\nexport function rowCollapseOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return ROW_VARIANTS;\n}\n\nexport function surfaceCollapseOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SURFACE_VARIANTS;\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/collapse.ts"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n};\n\nexport const PEOPLE = {\n  amara: {\n    id: 'amara',\n    name: 'Amara Okafor',\n  },\n  leo: {\n    id: 'leo',\n    name: 'Léo Martin',\n  },\n  sana: {\n    id: 'sana',\n    name: 'Sana Iqbal',\n  },\n  tomas: {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n  },\n  mei: {\n    id: 'mei',\n    name: 'Mei Lin',\n  },\n  jonas: { id: 'jonas', name: 'Jonas Weber' },\n} satisfies Record<string, Person>;\n\nexport function firstNameOf(person: Person) {\n  const [firstName] = person.name.split(' ');\n  return firstName;\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/sender-avatar.tsx",
      "content": "import {\n  AtSignIcon,\n  CircleDashedIcon,\n  MailIcon,\n  MessageCircleIcon,\n  UserRoundPlusIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport type { Person } from './people';\n\nexport type NotificationKind =\n  'mention' | 'comment' | 'assigned' | 'status' | 'invite';\n\ntype KindMeta = { label: string; color: string; glyph: Icon };\n\nexport const KIND_META: Record<NotificationKind, KindMeta> = {\n  mention: { label: 'Mention', color: 'var(--info)', glyph: AtSignIcon },\n  comment: {\n    label: 'Comment',\n    color: 'var(--success)',\n    glyph: MessageCircleIcon,\n  },\n  assigned: {\n    label: 'Assignment',\n    color: 'var(--primary)',\n    glyph: UserRoundPlusIcon,\n  },\n  status: {\n    label: 'Status change',\n    color: 'var(--muted-foreground)',\n    glyph: CircleDashedIcon,\n  },\n  invite: { label: 'Invitation', color: 'var(--warning)', glyph: MailIcon },\n};\n\ntype SenderAvatarProps = {\n  person: Person;\n  kind: NotificationKind;\n  /** The colour behind the badge's cut-out: the surface the avatar sits on. */\n  surfaceClassName?: string;\n  className?: string;\n};\n\n/** A sender's face with the kind of the notification on its lower corner. */\nexport function SenderAvatar({\n  person,\n  kind,\n  surfaceClassName = 'bg-card',\n  className,\n}: SenderAvatarProps) {\n  const meta = KIND_META[kind];\n  const Glyph = meta.glyph;\n  const badgeStyle = { '--chip': meta.color } as React.CSSProperties;\n\n  return (\n    <span className={cn('relative inline-flex shrink-0', className)}>\n      <Avatar className='size-8' seed={person.name} />\n      <span\n        aria-hidden='true'\n        className={cn(\n          'absolute -end-1 -bottom-1 inline-flex rounded-full p-px',\n          surfaceClassName\n        )}\n      >\n        <span\n          style={badgeStyle}\n          className='chip-3d inline-flex size-4 items-center justify-center rounded-full'\n        >\n          <Glyph strokeWidth={2.25} className='size-2.5' />\n        </span>\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/sender-avatar.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/swap.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: [0.2, 0, 0, 1] };\n\nconst SWAP_VARIANTS: Variants = {\n  enter: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  enter: { opacity: 0 },\n  visible: { opacity: 1 },\n  exit: { opacity: 0 },\n};\n\nfunction variantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\ntype SwapProps = {\n  /** Changes when the content becomes another one. */\n  swapKey: string;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/** An icon that becomes another: the old one shrinks out of focus while the new one settles. */\nexport function Swap({ swapKey, className, children }: SwapProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  return (\n    <AnimatePresence mode='popLayout' initial={false}>\n      <motion.span\n        key={swapKey}\n        variants={variantsOf(shouldReduceMotion)}\n        initial='enter'\n        animate='visible'\n        exit='exit'\n        transition={SWAP_TRANSITION}\n        className={cn('flex', className)}\n      >\n        {children}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/swap.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/unread-dot.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, type Transition } from 'motion/react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst EASE_OUT_STRONG: [number, number, number, number] = [0.23, 1, 0.32, 1];\n\nconst ARRIVE: Transition = { duration: 0.18, ease: EASE_OUT_STRONG };\n\nconst LEAVE: Transition = { duration: 0.12, ease: EASE_OUT_STRONG };\n\nfunction hiddenScaleOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 1;\n  }\n  return 0.4;\n}\n\ntype UnreadDotProps = {\n  isUnread: boolean;\n  className?: string;\n};\n\n/** The primary dot of an unread notification. It shrinks away when the notification is read. */\nexport function UnreadDot({ isUnread, className }: UnreadDotProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const hiddenScale = hiddenScaleOf(shouldReduceMotion);\n\n  return (\n    <AnimatePresence initial={false}>\n      {isUnread && (\n        <motion.span\n          key='unread'\n          aria-hidden='true'\n          initial={{ opacity: 0, scale: hiddenScale }}\n          animate={{ opacity: 1, scale: 1, transition: ARRIVE }}\n          exit={{ opacity: 0, scale: hiddenScale, transition: LEAVE }}\n          className={cn('bg-primary block size-1.5 rounded-full', className)}\n        />\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/unread-dot.tsx"
    }
  ],
  "categories": [
    "components",
    "notification"
  ],
  "type": "registry:component"
}