{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "project-pulse",
  "type": "registry:component",
  "title": "Project Pulse",
  "description": "A project in two lines that opens into its tasks: the ring gives every task its own arc in its owner's colour, tap a face to see just their work, and the status chip opens a picker.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/avatar.json",
    "https://coss.com/ui/r/menu.json"
  ],
  "files": [
    {
      "path": "registry/devl/project-pulse.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CalendarIcon, CheckIcon, ChevronDownIcon } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from \"@/components/ui/avatar\";\nimport { Menu, MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from \"@/components/ui/menu\";\nimport { cn } from \"@/lib/utils\";\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let placeFromRight = 0;\n  let other = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nexport type ProjectStatus = \"on-track\" | \"at-risk\" | \"off-track\" | \"done\";\n\nexport interface ProjectMember {\n  id: string;\n  name: string;\n}\n\nexport interface ProjectTask {\n  id: string;\n  title: string;\n  assignee: string;\n  done: boolean;\n}\n\nexport interface ProjectPulseProps {\n  name: string;\n  due: string;\n  members: ProjectMember[];\n  tasks: ProjectTask[];\n  defaultStatus?: ProjectStatus;\n  defaultOpen?: boolean;\n  onStatusChange?: (status: ProjectStatus) => void;\n  onTasksChange?: (tasks: ProjectTask[]) => void;\n  className?: string;\n}\n\nconst STATUS: Record<ProjectStatus, { label: string; dot: string; chip: string }> = {\n  \"on-track\": { label: \"On track\", dot: \"bg-success\", chip: \"bg-success/10 text-success-foreground\" },\n  \"at-risk\": { label: \"At risk\", dot: \"bg-warning\", chip: \"bg-warning/12 text-warning-foreground\" },\n  \"off-track\": { label: \"Off track\", dot: \"bg-destructive\", chip: \"bg-destructive/10 text-destructive-foreground\" },\n  done: { label: \"Done\", dot: \"bg-info\", chip: \"bg-info/10 text-info-foreground\" },\n};\n\ntype Tone = { stroke: string; fill: string; soft: string };\nconst PALETTE: Tone[] = [\n  { stroke: \"stroke-violet-500 dark:stroke-violet-400\", fill: \"bg-violet-500\", soft: \"bg-violet-500/16 text-violet-800 dark:text-violet-200\" },\n  { stroke: \"stroke-sky-500 dark:stroke-sky-400\", fill: \"bg-sky-500\", soft: \"bg-sky-500/16 text-sky-800 dark:text-sky-200\" },\n  { stroke: \"stroke-amber-500 dark:stroke-amber-400\", fill: \"bg-amber-500\", soft: \"bg-amber-500/18 text-amber-800 dark:text-amber-200\" },\n  { stroke: \"stroke-emerald-500 dark:stroke-emerald-400\", fill: \"bg-emerald-500\", soft: \"bg-emerald-500/16 text-emerald-800 dark:text-emerald-200\" },\n  { stroke: \"stroke-rose-500 dark:stroke-rose-400\", fill: \"bg-rose-500\", soft: \"bg-rose-500/16 text-rose-800 dark:text-rose-200\" },\n];\n\nfunction initials(name: string) {\n  return name\n    .split(\" \")\n    .map((p) => p[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n}\n\nfunction arc(cx: number, cy: number, r: number, start: number, end: number) {\n  const toXY = (deg: number) => {\n    const rad = ((deg - 90) * Math.PI) / 180;\n    return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];\n  };\n  const [x0, y0] = toXY(start);\n  const [x1, y1] = toXY(end);\n  const large = end - start > 180 ? 1 : 0;\n  return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`;\n}\n\nfunction SegmentRing({\n  tasks,\n  colorOf,\n  focus,\n  size = 48,\n}: {\n  tasks: ProjectTask[];\n  colorOf: (id: string) => Tone;\n  focus: string | null;\n  size?: number;\n}) {\n  const reduce = useReducedMotion();\n  const stroke = 4.5;\n  const r = (size - stroke) / 2;\n  const c = size / 2;\n  const n = Math.max(tasks.length, 1);\n  const step = 360 / n;\n  const gap = Math.min(10, 160 / n);\n  return (\n    <svg aria-hidden className=\"shrink-0\" height={size} viewBox={`0 0 ${size} ${size}`} width={size}>\n      {tasks.map((task, i) => {\n        const d = arc(c, c, r, i * step + gap / 2, (i + 1) * step - gap / 2);\n        const faded = focus !== null && task.assignee !== focus;\n        return (\n          <g key={task.id}>\n            <path className=\"stroke-muted-foreground/20\" d={d} fill=\"none\" strokeLinecap=\"round\" strokeWidth={stroke} />\n            <motion.path\n              animate={{ pathLength: task.done ? 1 : 0, opacity: task.done ? (faded ? 0.25 : 1) : 0 }}\n              className={colorOf(task.assignee).stroke}\n              d={d}\n              fill=\"none\"\n              initial={false}\n              strokeLinecap=\"round\"\n              strokeWidth={stroke}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.5, bounce: 0 }}\n            />\n          </g>\n        );\n      })}\n    </svg>\n  );\n}\n\nexport function ProjectPulse({\n  name,\n  due,\n  members,\n  tasks: initialTasks,\n  defaultStatus = \"on-track\",\n  defaultOpen = false,\n  onStatusChange,\n  onTasksChange,\n  className,\n}: ProjectPulseProps) {\n  const reduce = useReducedMotion();\n  const uid = useId().replace(/:/g, \"\");\n  const [tasks, setTasks] = useState(initialTasks);\n  const [status, setStatus] = useState<ProjectStatus>(defaultStatus);\n  const [filter, setFilter] = useState<string | null>(null);\n  const [open, setOpen] = useState(defaultOpen);\n\n  const colorOf = (id: string) => PALETTE[Math.max(0, members.findIndex((m) => m.id === id)) % PALETTE.length];\n  const memberOf = (id: string) => members.find((m) => m.id === id);\n  const done = tasks.filter((t) => t.done).length;\n  const visible = filter ? tasks.filter((t) => t.assignee === filter) : tasks;\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 300, damping: 30 };\n\n  const toggleTask = (id: string) => {\n    const next = tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));\n    setTasks(next);\n    onTasksChange?.(next);\n  };\n\n  const meta = STATUS[status];\n\n  return (\n    <div\n      className={cn(\"w-110 max-w-full rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n    >\n      <div className=\"flex items-center gap-3 p-4\">\n        <div className=\"relative\">\n          <SegmentRing colorOf={colorOf} focus={filter} tasks={tasks} />\n          <span className=\"absolute inset-0 flex items-center justify-center font-medium text-[11px] tabular-nums\">\n            <Roll text={`${Math.round((done / Math.max(tasks.length, 1)) * 100)}%`} />\n          </span>\n        </div>\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n          <button\n            aria-controls={`${uid}-body`}\n            aria-expanded={open}\n            className=\"min-w-0 cursor-pointer truncate rounded-sm text-left font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            onClick={() => setOpen((o) => !o)}\n            type=\"button\"\n          >\n            {name}\n          </button>\n          <div className=\"flex items-center gap-2.5 text-muted-foreground text-xs\">\n            <Menu>\n              <MenuTrigger\n                aria-label={`Status: ${meta.label}`}\n                className={cn(\n                  \"inline-flex h-5 shrink-0 items-center gap-1.5 rounded-full px-2 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                  meta.chip,\n                )}\n              >\n                <motion.span\n                  animate={{ scale: [1.6, 1] }}\n                  className={cn(\"size-1.5 rounded-full\", meta.dot)}\n                  key={status}\n                  transition={reduce ? { duration: 0 } : { duration: 0.35, ease: [0.23, 1, 0.32, 1] }}\n                />\n                {meta.label}\n                <ChevronDownIcon className=\"size-3 opacity-70\" />\n              </MenuTrigger>\n              <MenuPopup align=\"start\" className=\"min-w-40\">\n                <MenuGroup>\n                  <MenuGroupLabel>Project status</MenuGroupLabel>\n                  <MenuRadioGroup\n                    onValueChange={(v) => {\n                      setStatus(v as ProjectStatus);\n                      onStatusChange?.(v as ProjectStatus);\n                    }}\n                    value={status}\n                  >\n                    {(Object.keys(STATUS) as ProjectStatus[]).map((key) => (\n                      <MenuRadioItem closeOnClick key={key} value={key}>\n                        <span className=\"flex items-center gap-2\">\n                          <span className={cn(\"size-2 rounded-full\", STATUS[key].dot)} />\n                          {STATUS[key].label}\n                        </span>\n                      </MenuRadioItem>\n                    ))}\n                  </MenuRadioGroup>\n                </MenuGroup>\n              </MenuPopup>\n            </Menu>\n            <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n              <Roll className=\"font-medium text-foreground\" text={String(done)} />\n              <span>of {tasks.length} tasks</span>\n            </span>\n            <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n              <CalendarIcon className=\"size-3\" />\n              {due}\n            </span>\n          </div>\n        </div>\n        <AvatarGroup className=\"hidden [--avatar-size:--spacing(6)] sm:flex\">\n          {members.slice(0, 3).map((m) => (\n            <Avatar key={m.id}>\n              <AvatarFallback className={cn(\"text-[9px]\", colorOf(m.id).soft)}>\n                {initials(m.name)}\n              </AvatarFallback>\n            </Avatar>\n          ))}\n          {members.length > 3 && <AvatarGroupCount className=\"text-[9px] dark:text-foreground\">+{members.length - 3}</AvatarGroupCount>}\n        </AvatarGroup>\n        <button\n          aria-controls={`${uid}-body`}\n          aria-expanded={open}\n          aria-label={open ? \"Hide tasks\" : \"Show tasks\"}\n          className=\"-me-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          onClick={() => setOpen((o) => !o)}\n          type=\"button\"\n        >\n          <motion.span animate={{ rotate: open ? 180 : 0 }} className=\"inline-flex\" transition={spring}>\n            <ChevronDownIcon className=\"size-4\" />\n          </motion.span>\n        </button>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            id={`${uid}-body`}\n            initial={{ height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 32 }}\n          >\n            <div className=\"flex flex-col gap-3 border-t px-4 pt-3 pb-4\">\n              <div aria-label=\"Filter by assignee\" className=\"flex items-center gap-1.5\" role=\"group\">\n                <button\n                  aria-pressed={filter === null}\n                  className={cn(\n                    \"relative h-7 rounded-full px-2.5 font-medium text-xs outline-none transition-[background-color,color,transform] duration-150 ease-out after:absolute after:-inset-0.5 focus-visible:ring-2 focus-visible:ring-ring motion-safe:active:scale-[0.97]\",\n                    filter === null ? \"bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground hover:text-foreground\",\n                  )}\n                  onClick={() => setFilter(null)}\n                  type=\"button\"\n                >\n                  Everyone\n                </button>\n                {members.map((m) => {\n                  const active = filter === m.id;\n                  const count = tasks.filter((t) => t.assignee === m.id && !t.done).length;\n                  return (\n                    <motion.button\n                      animate={{ opacity: filter && !active ? 0.45 : 1, scale: active ? 1.08 : 1 }}\n                      aria-label={`${m.name}, ${count} open`}\n                      aria-pressed={active}\n                      className=\"relative rounded-full outline-none after:absolute after:-inset-0.5 after:rounded-full focus-visible:ring-2 focus-visible:ring-ring\"\n                      key={m.id}\n                      onClick={() => setFilter(active ? null : m.id)}\n                      title={m.name}\n                      transition={spring}\n                      type=\"button\"\n                    >\n                      <Avatar className={cn(\"[--avatar-size:--spacing(7)]\", active && \"ring-2 ring-foreground/70 ring-offset-2 ring-offset-card\")}>\n                        <AvatarFallback className={cn(\"font-medium text-[10px]\", colorOf(m.id).soft)}>\n                          {initials(m.name)}\n                        </AvatarFallback>\n                      </Avatar>\n                      {count > 0 && (\n                        <span className=\"absolute -top-1 -right-1 flex size-3.5 items-center justify-center rounded-full border border-card bg-foreground font-medium text-[8px] text-background tabular-nums\">\n                          {count}\n                        </span>\n                      )}\n                    </motion.button>\n                  );\n                })}\n              </div>\n\n              <motion.ul className=\"flex flex-col\" layout={!reduce} transition={spring}>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {visible.map((task) => {\n                    const member = memberOf(task.assignee);\n                    return (\n                      <motion.li\n                        animate={{ opacity: 1, y: 0 }}\n                        exit={{ opacity: 0, y: -6 }}\n                        initial={{ opacity: 0, y: 6 }}\n                        key={task.id}\n                        layout={!reduce}\n                        transition={spring}\n                      >\n                        <button\n                          aria-pressed={task.done}\n                          className=\"group flex w-full items-center gap-2.5 rounded-lg px-1.5 py-1.5 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\"\n                          onClick={() => toggleTask(task.id)}\n                          type=\"button\"\n                        >\n                          <span\n                            className={cn(\n                              \"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors\",\n                              task.done ? cn(\"border-transparent text-white\", colorOf(task.assignee).fill) : \"border-input\",\n                            )}\n                          >\n                            <AnimatePresence initial={false}>\n                              {task.done && (\n                                <motion.span\n                                  animate={{ opacity: 1, scale: 1 }}\n                                  className=\"inline-flex\"\n                                  exit={{ opacity: 0, scale: 0.6 }}\n                                  initial={{ opacity: 0, scale: 0.6 }}\n                                  transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.2, bounce: 0 }}\n                                >\n                                  <CheckIcon className=\"size-2.5\" strokeWidth={3} />\n                                </motion.span>\n                              )}\n                            </AnimatePresence>\n                          </span>\n                          <span\n                            className={cn(\n                              \"flex-1 truncate text-sm transition-colors\",\n                              task.done && \"text-muted-foreground line-through decoration-muted-foreground/50\",\n                            )}\n                          >\n                            {task.title}\n                          </span>\n                          <span className=\"text-muted-foreground text-xs\">{member?.name.split(\" \")[0]}</span>\n                        </button>\n                      </motion.li>\n                    );\n                  })}\n                </AnimatePresence>\n              </motion.ul>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}