{"version":1,"id":"todo-list","framework":"next","kind":"block","files":[{"path":"src/blocks/gfa/todo-list.tsx","content":"\"use client\";\n// GFA todo list (beui agents/todo-list)\n\nimport { ChevronDown, ListTodo } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { ActionSwapRollText } from \"@/components/motion/action-swap-roll\";\nimport { AgentDisclosure } from \"@/components/agents/agent-disclosure\";\nimport {\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_SWAP,\n} from \"@/lib/ease\";\nimport { playTodoSend, playTodoSuccess } from \"@/lib/todo-list-sounds\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TodoItemStatus =\n  | \"pending\"\n  | \"in-progress\"\n  | \"completed\"\n  | \"cancelled\";\n\nexport interface TodoItem {\n  id: string;\n  title: ReactNode;\n  status?: TodoItemStatus;\n  progress?: number;\n  detail?: ReactNode;\n}\n\nexport interface TodoListProps {\n  items: TodoItem[];\n  title?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  collapseOnComplete?: boolean;\n  maxHeight?: number;\n  className?: string;\n  /** When false, step/success sounds do not play. Default true. */\n  soundEnabled?: boolean;\n}\n\nfunction statusLabel(status: TodoItemStatus) {\n  if (status === \"in-progress\") return \"In progress\";\n  if (status === \"completed\") return \"Completed\";\n  if (status === \"cancelled\") return \"Cancelled\";\n  return \"Pending\";\n}\n\nfunction TodoHeaderIcon({ complete }: { complete: boolean }) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative grid size-6 shrink-0 place-items-center\"\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {complete ? (\n          <motion.svg\n            key=\"complete\"\n            viewBox=\"0 0 24 24\"\n            initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.72 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0 }}\n            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n            className=\"absolute size-5.5 overflow-visible text-emerald-500\"\n          >\n            <circle cx=\"12\" cy=\"12\" r=\"9\" fill=\"currentColor\" />\n            <motion.path\n              d=\"M7.5 12.25 10.5 15.25 16.75 8.75\"\n              fill=\"none\"\n              stroke=\"white\"\n              strokeWidth=\"2.25\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }\n              }\n            />\n          </motion.svg>\n        ) : (\n          <motion.span\n            key=\"todo\"\n            initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.8 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.72 }}\n            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n            className=\"absolute grid place-items-center text-muted-foreground\"\n          >\n            <ListTodo className=\"size-4\" />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction TodoStatusIcon({\n  status,\n  progress,\n}: {\n  status: TodoItemStatus;\n  progress?: number;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const normalizedProgress =\n    progress === undefined ? 0.68 : Math.min(100, Math.max(0, progress)) / 100;\n\n  return (\n    <motion.svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      initial={false}\n      className={cn(\n        \"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground\",\n        status === \"in-progress\" && \"text-foreground\",\n        status === \"cancelled\" && \"text-rose-600 dark:text-rose-400\",\n      )}\n    >\n      <motion.circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"9\"\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeDasharray={status === \"pending\" ? \"2 3\" : undefined}\n        strokeLinecap=\"round\"\n        initial={false}\n        animate={{ fillOpacity: status === \"completed\" ? 0.06 : 0 }}\n        transition={reduce ? { duration: 0 } : { duration: 0.18, ease: EASE_OUT }}\n        className={cn(status === \"in-progress\" && \"opacity-20\")}\n      />\n      <motion.circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"9\"\n        pathLength=\"1\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        initial={false}\n        animate={{\n          pathLength: status === \"in-progress\" ? normalizedProgress : 0,\n          opacity: status === \"in-progress\" ? 1 : 0,\n          rotate:\n            status === \"in-progress\" && progress === undefined && !reduce\n              ? 360\n              : -90,\n        }}\n        transition={\n          status === \"in-progress\" && progress === undefined && !reduce\n            ? { rotate: { duration: 1.1, repeat: Infinity, ease: \"linear\" } }\n            : reduce\n              ? { duration: 0 }\n              : SPRING_LAYOUT\n        }\n        style={{ transformOrigin: \"12px 12px\" }}\n      />\n      <motion.path\n        d=\"M7.5 12.25 10.5 15.25 16.75 8.75\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={false}\n        animate={{\n          pathLength: status === \"completed\" ? 1 : 0,\n          opacity: status === \"completed\" ? 1 : 0,\n        }}\n        transition={reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }}\n      />\n      <motion.path\n        d=\"M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        initial={false}\n        animate={{\n          pathLength: status === \"cancelled\" ? 1 : 0,\n          opacity: status === \"cancelled\" ? 1 : 0,\n        }}\n        transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}\n      />\n    </motion.svg>\n  );\n}\n\nexport function TodoList({\n  items,\n  title = \"To-dos\",\n  open,\n  defaultOpen = true,\n  onOpenChange,\n  collapseOnComplete = true,\n  maxHeight = 248,\n  className,\n  soundEnabled = true,\n}: TodoListProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const triggerId = `${baseId}-trigger`;\n  const contentId = `${baseId}-content`;\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const previousComplete = useRef(false);\n  const previousCompletedIds = useRef<Set<string>>(new Set());\n  const soundsReady = useRef(false);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const currentOpen = open ?? internalOpen;\n  const completed = items.filter((item) => item.status === \"completed\").length;\n  const allComplete = items.length > 0 && completed === items.length;\n  const itemCount = items.length;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (open === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, open],\n  );\n\n  useEffect(() => {\n    const completedIds = new Set(\n      items\n        .filter((item) => (item.status ?? \"pending\") === \"completed\")\n        .map((item) => item.id),\n    );\n\n    if (soundsReady.current) {\n      for (const id of completedIds) {\n        if (!previousCompletedIds.current.has(id)) {\n          if (soundEnabled) playTodoSend();\n          break;\n        }\n      }\n    }\n\n    previousCompletedIds.current = completedIds;\n    soundsReady.current = true;\n  }, [items, soundEnabled]);\n\n  useEffect(() => {\n    if (previousComplete.current && !allComplete) {\n      setOpen(true);\n    }\n    if (!previousComplete.current && allComplete) {\n      if (soundEnabled) playTodoSuccess();\n      if (collapseOnComplete) setOpen(false);\n    }\n    previousComplete.current = allComplete;\n  }, [allComplete, collapseOnComplete, setOpen, soundEnabled]);\n\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport || itemCount === 0) return;\n\n    const frame = requestAnimationFrame(() => {\n      if (viewport.scrollHeight <= viewport.clientHeight) return;\n      if (typeof viewport.scrollTo === \"function\") {\n        viewport.scrollTo({\n          top: viewport.scrollHeight,\n          behavior: reduce ? \"auto\" : \"smooth\",\n        });\n      } else {\n        viewport.scrollTop = viewport.scrollHeight;\n      }\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [itemCount, reduce]);\n\n  return (\n    <section\n      aria-label=\"Agent task list\"\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border border-border/70\",\n        className,\n      )}\n    >\n      <button\n        id={triggerId}\n        type=\"button\"\n        aria-expanded={currentOpen}\n        aria-controls={contentId}\n        onClick={() => setOpen(!currentOpen)}\n        className=\"group flex h-11 w-full items-center gap-2.5 rounded-2xl px-3.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <TodoHeaderIcon complete={allComplete} />\n        <h3 className=\"min-w-0 flex-1 truncate text-sm font-medium text-foreground/90\">\n          {title}\n        </h3>\n        <span\n          className={cn(\n            \"shrink-0 text-xs font-medium tabular-nums text-muted-foreground\",\n            allComplete && \"text-emerald-600 dark:text-emerald-400\",\n          )}\n        >\n          <span className=\"sr-only\">\n            {completed} of {items.length} tasks completed\n          </span>\n          <span aria-hidden=\"true\" className=\"inline-flex\">\n            <ActionSwapRollText value={String(completed)}>\n              {completed}\n            </ActionSwapRollText>\n            <span>/</span>\n            <span>{items.length}</span>\n          </span>\n        </span>\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ rotate: currentOpen ? 180 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING_SWAP}\n          className=\"text-muted-foreground/50 transition-colors group-hover:text-muted-foreground\"\n        >\n          <ChevronDown className=\"size-3.5\" />\n        </motion.span>\n      </button>\n\n      <AgentDisclosure\n        id={contentId}\n        role=\"region\"\n        aria-labelledby={triggerId}\n        open={currentOpen}\n      >\n        <div\n          ref={viewportRef}\n          className=\"scrollbar-hide overflow-y-auto px-2 pb-2\"\n          style={{ maxHeight }}\n        >\n          {items.length ? (\n            <ol aria-live=\"polite\" className=\"space-y-0\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {items.map((item) => {\n                const status = item.status ?? \"pending\";\n                return (\n                  <motion.li\n                    layout=\"position\"\n                    key={item.id}\n                    initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n                    transition={\n                      reduce\n                        ? { duration: 0 }\n                        : {\n                            opacity: { duration: 0.18, ease: EASE_OUT },\n                            y: SPRING_LAYOUT,\n                            layout: SPRING_LAYOUT,\n                          }\n                    }\n                    className=\"flex min-h-9 items-center gap-2.5 rounded-xl px-1.5 py-1\"\n                  >\n                    <TodoStatusIcon status={status} progress={item.progress} />\n                    <span className=\"sr-only\">{statusLabel(status)}: </span>\n                    <span\n                      className={cn(\n                        \"min-w-0 flex-1 truncate text-sm leading-5\",\n                        status === \"pending\" && \"text-muted-foreground/65\",\n                        status === \"in-progress\" && \"text-foreground\",\n                        status === \"completed\" && \"text-muted-foreground/60\",\n                        status === \"cancelled\" && \"text-muted-foreground/55\",\n                      )}\n                    >\n                      <span className=\"relative inline-block max-w-full\">\n                        {item.title}\n                        <motion.span\n                          aria-hidden=\"true\"\n                          initial={false}\n                          animate={{\n                            scaleX: status === \"completed\" ? 1 : 0,\n                            opacity: status === \"completed\" ? 1 : 0,\n                          }}\n                          transition={\n                            reduce\n                              ? { duration: 0 }\n                              : { duration: 0.28, ease: EASE_OUT, delay: 0.06 }\n                          }\n                          className=\"absolute inset-x-0 top-1/2 h-px origin-left bg-current\"\n                        />\n                      </span>\n                    </span>\n                    {item.detail ? (\n                      <span className=\"shrink-0 text-sm text-muted-foreground/55\">\n                        {item.detail}\n                      </span>\n                    ) : null}\n                  </motion.li>\n                );\n              })}\n            </AnimatePresence>\n            </ol>\n          ) : (\n            <p className=\"px-1.5 py-2 text-sm text-muted-foreground\">\n              No tasks yet\n            </p>\n          )}\n        </div>\n      </AgentDisclosure>\n    </section>\n  );\n}\n\nexport { TodoList as TodoListBlock };\nexport type { TodoListProps as TodoListBlockProps };\n"}],"meta":{"studioName":"GFA Development & Design Studio","siteUrl":"https://ui.gkhn.dev"}}