{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"conversation-history","type":"registry:component","title":"Conversation History","description":"Chat history sidebar list grouped into Pinned, Today, Yesterday, Previous 7 days and Older from a now read after mount, with search, a spring-gliding active highlight, hover or focus row actions to pin, rename in place through InlineEdit and delete with a slide-out exit while neighbours glide up, and new chats that drop in at the top with a streaming title caret.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/conversation-history.tsx","type":"registry:component","target":"@components/agents/conversation-history.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/conversation-history\n\nimport { MessageSquare, Pencil, Pin, PinOff, Plus, Search, Trash2 } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EmptyState } from \"@/components/motion/empty-state\";\nimport { InlineEdit } from \"@/components/motion/inline-edit\";\nimport { ScrollArea } from \"@/components/motion/scroll-area\";\nimport { Skeleton } from \"@/components/motion/skeleton\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Conversation {\n  id: string;\n  title: string;\n  /** Last activity. ISO string, epoch ms or Date. */\n  updatedAt: string | number | Date;\n  pinned?: boolean;\n  /** The title is still being generated: shows a blinking caret after it. */\n  streaming?: boolean;\n}\n\nexport type ConversationGroup = \"pinned\" | \"today\" | \"yesterday\" | \"week\" | \"older\";\n\nexport interface ConversationHistoryProps {\n  conversations?: Conversation[];\n  defaultConversations?: Conversation[];\n  onConversationsChange?: (conversations: Conversation[]) => void;\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveIdChange?: (id: string) => void;\n  /** Called after a rename commits; return a promise to keep the editor's spinner until it settles. */\n  onRename?: (id: string, title: string) => unknown;\n  onPinChange?: (id: string, pinned: boolean) => void;\n  onDelete?: (id: string) => void;\n  /** Renders the \"New chat\" button. The parent inserts the conversation (usually `streaming`). */\n  onNewChat?: () => void;\n  /** Override \"today\" (otherwise read after mount so server and client markup match). */\n  now?: Date;\n  searchable?: boolean;\n  labels?: Partial<Record<ConversationGroup, string>>;\n  /** Max height of the scrolling list. Default 320. */\n  maxHeight?: number;\n  emptyState?: ReactNode;\n  className?: string;\n}\n\nconst GROUP_LABELS: Record<ConversationGroup, string> = {\n  pinned: \"Pinned\",\n  today: \"Today\",\n  yesterday: \"Yesterday\",\n  week: \"Previous 7 days\",\n  older: \"Older\",\n};\n\nconst GROUP_ORDER: ConversationGroup[] = [\"pinned\", \"today\", \"yesterday\", \"week\", \"older\"];\nconst DAY_MS = 86_400_000;\n\nfunction toTime(value: Conversation[\"updatedAt\"]) {\n  return value instanceof Date ? value.getTime() : new Date(value).getTime();\n}\n\n/** Bucket a conversation by calendar day relative to `now` (local time). */\nexport function groupConversation(conversation: Conversation, now: Date): ConversationGroup {\n  if (conversation.pinned) return \"pinned\";\n  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();\n  const t = toTime(conversation.updatedAt);\n  if (t >= startOfToday) return \"today\";\n  if (t >= startOfToday - DAY_MS) return \"yesterday\";\n  if (t >= startOfToday - 7 * DAY_MS) return \"week\";\n  return \"older\";\n}\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\nexport function ConversationHistory({\n  conversations: conversationsProp,\n  defaultConversations = [],\n  onConversationsChange,\n  activeId: activeIdProp,\n  defaultActiveId = null,\n  onActiveIdChange,\n  onRename,\n  onPinChange,\n  onDelete,\n  onNewChat,\n  now: nowProp,\n  searchable = true,\n  labels,\n  maxHeight = 320,\n  emptyState,\n  className,\n}: ConversationHistoryProps) {\n  const uid = useId();\n  const reduce = useReducedMotion() ?? false;\n  const [conversations, setConversations] = useControllable(\n    conversationsProp,\n    defaultConversations,\n    onConversationsChange,\n  );\n  const [activeId, setActiveId] = useControllable<string | null>(\n    activeIdProp,\n    defaultActiveId,\n    onActiveIdChange as ((id: string | null) => void) | undefined,\n  );\n  const [query, setQuery] = useState(\"\");\n  const [renamingId, setRenamingId] = useState<string | null>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  // \"Today\" is only known on the client; grouping waits for it so the server\n  // render never disagrees with the first client render.\n  const [mountedNow, setMountedNow] = useState<Date | null>(null);\n  useEffect(() => {\n    if (!nowProp) setMountedNow(new Date());\n  }, [nowProp]);\n  const now = nowProp ?? mountedNow;\n\n  const groups = useMemo(() => {\n    if (!now) return [];\n    const q = query.trim().toLowerCase();\n    const buckets = new Map<ConversationGroup, Conversation[]>();\n    const sorted = [...conversations].sort((a, b) => toTime(b.updatedAt) - toTime(a.updatedAt));\n    for (const c of sorted) {\n      if (q && !c.title.toLowerCase().includes(q)) continue;\n      const g = groupConversation(c, now);\n      const list = buckets.get(g) ?? [];\n      list.push(c);\n      buckets.set(g, list);\n    }\n    return GROUP_ORDER.filter((g) => buckets.has(g)).map((g) => ({\n      id: g,\n      items: buckets.get(g) ?? [],\n    }));\n  }, [conversations, now, query]);\n\n  const focusRow = (id: string) => {\n    requestAnimationFrame(() => {\n      listRef.current\n        ?.querySelector<HTMLElement>(`[data-conversation-id=\"${CSS.escape(id)}\"]`)\n        ?.focus();\n    });\n  };\n\n  const update = (id: string, patch: Partial<Conversation>) =>\n    setConversations(conversations.map((c) => (c.id === id ? { ...c, ...patch } : c)));\n\n  const remove = (id: string) => {\n    const flat = groups.flatMap((g) => g.items);\n    const index = flat.findIndex((c) => c.id === id);\n    const neighbour = flat[index + 1] ?? flat[index - 1];\n    setConversations(conversations.filter((c) => c.id !== id));\n    onDelete?.(id);\n    if (neighbour) focusRow(neighbour.id);\n  };\n\n  // Up/Down (and Home/End) move between conversation rows.\n  const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const target = event.target as HTMLElement;\n    if (!target.dataset.conversationId) return;\n    const rows = Array.from(\n      listRef.current?.querySelectorAll<HTMLElement>(\"[data-conversation-id]\") ?? [],\n    );\n    const i = rows.indexOf(target);\n    const next =\n      event.key === \"Home\"\n        ? rows[0]\n        : event.key === \"End\"\n          ? rows[rows.length - 1]\n          : rows[i + (event.key === \"ArrowDown\" ? 1 : -1)];\n    if (next) {\n      event.preventDefault();\n      next.focus();\n    }\n  };\n\n  const total = groups.reduce((n, g) => n + g.items.length, 0);\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-2\", className)}>\n      {(onNewChat || searchable) && (\n        <div className=\"flex items-center gap-2\">\n          {searchable && (\n            <label className=\"relative flex h-9 min-w-0 flex-1 items-center rounded-lg border border-border bg-muted/50 focus-within:border-border-strong focus-within:ring-2 focus-within:ring-ring/30\">\n              <Search aria-hidden=\"true\" className=\"pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground\" />\n              <input\n                type=\"search\"\n                value={query}\n                onChange={(e) => setQuery(e.target.value)}\n                placeholder=\"Search chats\"\n                aria-label=\"Search chats\"\n                className=\"h-full w-full bg-transparent pl-8 pr-2 text-sm text-foreground outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden\"\n              />\n            </label>\n          )}\n          {onNewChat && (\n            <button\n              type=\"button\"\n              onClick={onNewChat}\n              aria-label=\"New chat\"\n              className=\"inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\"\n            >\n              <Plus aria-hidden=\"true\" className=\"size-4\" />\n              <span className={searchable ? \"sr-only sm:not-sr-only\" : undefined}>New chat</span>\n            </button>\n          )}\n        </div>\n      )}\n\n      <ScrollArea\n        aria-label=\"Conversation history\"\n        fadeEdges\n        className=\"w-full\"\n        viewportClassName=\"max-h-[var(--ch-max)]\"\n        style={{ \"--ch-max\": `${maxHeight}px` } as CSSProperties}\n      >\n        {/* biome-ignore lint/a11y/noStaticElementInteractions: delegates arrow keys to the row buttons inside */}\n        <div ref={listRef} onKeyDown={onListKeyDown} className=\"flex flex-col gap-3 p-1\">\n          {!now ? (\n            <div aria-busy=\"true\" className=\"flex flex-col gap-2 px-1\">\n              <Skeleton shape=\"text\" width=\"30%\" />\n              {[72, 58, 80, 64].map((w) => (\n                <Skeleton key={w} height={32} width={`${w}%`} />\n              ))}\n            </div>\n          ) : total === 0 ? (\n            (emptyState ?? (\n              <EmptyState\n                size=\"sm\"\n                icon={<MessageSquare aria-hidden=\"true\" className=\"size-5\" />}\n                title={query ? \"No matching chats\" : \"No conversations yet\"}\n                description={query ? `Nothing matches “${query}”.` : \"Start a new chat to see it here.\"}\n              />\n            ))\n          ) : (\n            <LayoutGroup id={uid}>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {groups.map((group) => (\n                  <motion.section\n                    key={group.id}\n                    layout={reduce ? false : \"position\"}\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                    transition={SPRING_LAYOUT}\n                    aria-labelledby={`${uid}-${group.id}`}\n                    className=\"flex flex-col gap-0.5\"\n                  >\n                    <h3\n                      id={`${uid}-${group.id}`}\n                      className=\"flex items-center gap-1.5 px-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground\"\n                    >\n                      {group.id === \"pinned\" && <Pin aria-hidden=\"true\" className=\"size-3\" />}\n                      {labels?.[group.id] ?? GROUP_LABELS[group.id]}\n                    </h3>\n                    <ul className=\"flex flex-col gap-0.5\">\n                      <AnimatePresence initial={false} mode=\"popLayout\">\n                        {group.items.map((conversation) => (\n                          <ConversationRow\n                            key={conversation.id}\n                            uid={uid}\n                            conversation={conversation}\n                            active={conversation.id === activeId}\n                            renaming={renamingId === conversation.id}\n                            reduce={reduce}\n                            onSelect={() => setActiveId(conversation.id)}\n                            onStartRename={() => setRenamingId(conversation.id)}\n                            onEndRename={() => {\n                              setRenamingId(null);\n                              focusRow(conversation.id);\n                            }}\n                            onRename={(title) => {\n                              update(conversation.id, { title });\n                              return onRename?.(conversation.id, title);\n                            }}\n                            onTogglePin={() => {\n                              const pinned = !conversation.pinned;\n                              update(conversation.id, { pinned });\n                              onPinChange?.(conversation.id, pinned);\n                              focusRow(conversation.id);\n                            }}\n                            onDelete={() => remove(conversation.id)}\n                          />\n                        ))}\n                      </AnimatePresence>\n                    </ul>\n                  </motion.section>\n                ))}\n              </AnimatePresence>\n            </LayoutGroup>\n          )}\n        </div>\n      </ScrollArea>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {query && now ? `${total} conversation${total === 1 ? \"\" : \"s\"} found` : \"\"}\n      </p>\n    </div>\n  );\n}\n\ninterface ConversationRowProps {\n  uid: string;\n  conversation: Conversation;\n  active: boolean;\n  renaming: boolean;\n  reduce: boolean;\n  onSelect: () => void;\n  onStartRename: () => void;\n  onEndRename: () => void;\n  onRename: (title: string) => unknown;\n  onTogglePin: () => void;\n  onDelete: () => void;\n}\n\nfunction ConversationRow({\n  uid,\n  conversation,\n  active,\n  renaming,\n  reduce,\n  onSelect,\n  onStartRename,\n  onEndRename,\n  onRename,\n  onTogglePin,\n  onDelete,\n}: ConversationRowProps) {\n  const editRef = useRef<HTMLDivElement>(null);\n\n  // InlineEdit opens from its own display button; press it once so the\n  // Rename action lands straight in the editor.\n  useEffect(() => {\n    if (renaming) editRef.current?.querySelector<HTMLButtonElement>(\"button\")?.click();\n  }, [renaming]);\n\n  return (\n    <motion.li\n      layout={reduce ? false : \"position\"}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: -10, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.12 } }\n          : { opacity: 0, x: -24, transition: { duration: 0.18, ease: EASE_OUT } }\n      }\n      transition={SPRING_LAYOUT}\n      className=\"group/row relative\"\n    >\n      {active && (\n        <motion.span\n          aria-hidden=\"true\"\n          layoutId={`${uid}-active`}\n          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n          className=\"absolute inset-0 rounded-lg bg-muted ring-1 ring-border\"\n        />\n      )}\n      {renaming ? (\n        <div ref={editRef} className=\"relative px-2 py-0.5 text-sm\">\n          <InlineEdit\n            label=\"Conversation title\"\n            defaultValue={conversation.title}\n            maxLength={80}\n            validate={(next) => (next.trim() ? null : \"Title can't be empty\")}\n            onSave={(next) => onRename(next.trim())}\n            onEditingChange={(editing) => {\n              if (!editing) onEndRename();\n            }}\n            className=\"mx-0\"\n          />\n        </div>\n      ) : (\n        <>\n          <button\n            type=\"button\"\n            data-conversation-id={conversation.id}\n            aria-current={active ? \"page\" : undefined}\n            onClick={onSelect}\n            onKeyDown={(e) => {\n              if (e.key === \"F2\") {\n                e.preventDefault();\n                onStartRename();\n              } else if (e.key === \"Delete\") {\n                e.preventDefault();\n                onDelete();\n              }\n            }}\n            className={cn(\n              \"relative flex h-9 w-full items-center rounded-lg px-2.5 pr-2 text-left text-sm outline-none transition-colors\",\n              \"focus-visible:ring-2 focus-visible:ring-ring\",\n              active ? \"text-foreground\" : \"text-muted-foreground hover:bg-foreground/5 hover:text-foreground\",\n            )}\n          >\n            <span className=\"min-w-0 flex-1 truncate group-focus-within/row:pr-20 group-hover/row:pr-20\">\n              {conversation.title ||\n                (conversation.streaming ? (\n                  <span className=\"text-muted-foreground\">New chat</span>\n                ) : (\n                  \"Untitled\"\n                ))}\n              {conversation.streaming && (\n                <motion.span\n                  aria-hidden=\"true\"\n                  className=\"ml-0.5 inline-block h-3.5 w-px translate-y-0.5 bg-foreground\"\n                  animate={{ opacity: [1, 0, 1] }}\n                  transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY }}\n                />\n              )}\n            </span>\n            {conversation.streaming && <span className=\"sr-only\">(generating title)</span>}\n          </button>\n          <div\n            className={cn(\n              \"absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity duration-150\",\n              \"group-focus-within/row:opacity-100 group-hover/row:opacity-100 pointer-coarse:opacity-100\",\n            )}\n          >\n            <RowAction label={conversation.pinned ? \"Unpin\" : \"Pin\"} onClick={onTogglePin}>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={conversation.pinned ? \"off\" : \"on\"}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -30 }}\n                  animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}\n                  transition={SPRING_SWAP}\n                  className=\"flex\"\n                >\n                  {conversation.pinned ? <PinOff className=\"size-3.5\" /> : <Pin className=\"size-3.5\" />}\n                </motion.span>\n              </AnimatePresence>\n            </RowAction>\n            <RowAction label=\"Rename\" onClick={onStartRename}>\n              <Pencil className=\"size-3.5\" />\n            </RowAction>\n            <RowAction label=\"Delete\" destructive onClick={onDelete}>\n              <Trash2 className=\"size-3.5\" />\n            </RowAction>\n          </div>\n        </>\n      )}\n    </motion.li>\n  );\n}\n\nfunction RowAction({\n  label,\n  destructive,\n  onClick,\n  children,\n}: {\n  label: string;\n  destructive?: boolean;\n  onClick: () => void;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      className={cn(\n        \"flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors\",\n        \"focus-visible:ring-2 focus-visible:ring-ring\",\n        destructive\n          ? \"hover:bg-destructive/10 hover:text-destructive\"\n          : \"hover:bg-foreground/10 hover:text-foreground\",\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n"},{"path":"components/motion/empty-state.tsx","type":"registry:component","target":"@components/motion/empty-state.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type EmptyStateSize = \"sm\" | \"md\" | \"lg\";\nexport type EmptyStateIllustrationVariant = \"documents\" | \"search\";\n\n/* -------------------------------------------------------------------------- */\n/*                                Illustration                                */\n/* -------------------------------------------------------------------------- */\n\nexport interface EmptyStateIllustrationProps {\n  variant?: EmptyStateIllustrationVariant;\n  /** Width in px; height follows the 5:4 viewBox. */\n  size?: number;\n  className?: string;\n}\n\nconst draw = (delay: number, reduce: boolean) => ({\n  initial: reduce ? { pathLength: 1, opacity: 0 } : { pathLength: 0, opacity: 0 },\n  whileInView: { pathLength: 1, opacity: 1 },\n  viewport: { once: true },\n  transition: reduce\n    ? { duration: 0.2 }\n    : {\n        pathLength: { duration: 0.5, ease: EASE_OUT, delay },\n        opacity: { duration: 0.1, delay },\n      },\n});\n\n// Idle float: translate only (no rotate/scale), so no SVG transform-origin is\n// involved. Rotations in the art are static `transform` attributes on an\n// inner <g> that motion never touches.\nconst float = (reduce: boolean, amplitude: number, duration: number, delay: number) =>\n  reduce\n    ? {}\n    : {\n        animate: { y: [0, -amplitude, 0] },\n        transition: { duration, ease: EASE_IN_OUT, repeat: Infinity, delay },\n      };\n\n/** Small default empty-state art: a floating document stack or a search lens. */\nexport function EmptyStateIllustration({\n  variant = \"documents\",\n  size = 120,\n  className,\n}: EmptyStateIllustrationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const lineClass = \"stroke-border-strong\";\n\n  return (\n    <svg\n      width={size}\n      height={(size * 4) / 5}\n      viewBox=\"0 0 120 96\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className={cn(\"overflow-visible\", className)}\n    >\n      <ellipse cx=\"60\" cy=\"88\" rx=\"30\" ry=\"3.5\" className=\"fill-muted\" />\n      {variant === \"documents\" ? (\n        <>\n          <motion.g {...float(reduce, 2, 3.4, 0.4)}>\n            <g transform=\"rotate(-9 46 48)\">\n              <rect x=\"24\" y=\"20\" width=\"44\" height=\"56\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            </g>\n          </motion.g>\n          <motion.g {...float(reduce, 2.5, 3.8, 0.9)}>\n            <g transform=\"rotate(8 74 48)\">\n              <rect x=\"52\" y=\"20\" width=\"44\" height=\"56\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            </g>\n          </motion.g>\n          <motion.g {...float(reduce, 3.5, 3, 0)}>\n            <rect x=\"37\" y=\"16\" width=\"46\" height=\"60\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-card stroke-border-strong\" />\n            <motion.path d=\"M46 30h20\" strokeWidth=\"3\" strokeLinecap=\"round\" className=\"stroke-foreground/70\" {...draw(0.25, reduce)} />\n            <motion.path d=\"M46 40h28\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.35, reduce)} />\n            <motion.path d=\"M46 49h24\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.45, reduce)} />\n            <motion.path d=\"M46 58h16\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.55, reduce)} />\n            <motion.g\n              initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n              whileInView={{ opacity: 1, y: 0 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.6 }}\n            >\n              <circle cx=\"84\" cy=\"17\" r=\"9\" className=\"fill-primary\" />\n              <path d=\"M84 13v8M80 17h8\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"stroke-primary-foreground\" />\n            </motion.g>\n          </motion.g>\n        </>\n      ) : (\n        <>\n          <motion.g {...float(reduce, 1.5, 3.6, 0.5)}>\n            <rect x=\"22\" y=\"22\" width=\"64\" height=\"50\" rx=\"8\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            <motion.path d=\"M32 36h30\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.2, reduce)} />\n            <motion.path d=\"M32 46h22\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.3, reduce)} />\n            <motion.path d=\"M32 56h26\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.4, reduce)} />\n          </motion.g>\n          <motion.g {...float(reduce, 3.5, 3, 0)}>\n            <circle cx=\"76\" cy=\"48\" r=\"17\" strokeWidth=\"1.5\" className=\"fill-card stroke-border-strong\" />\n            <motion.circle cx=\"76\" cy=\"48\" r=\"11\" strokeWidth=\"3\" className=\"stroke-foreground/70\" {...draw(0.35, reduce)} />\n            <motion.path d=\"M84.5 56.5 96 68\" strokeWidth=\"5\" strokeLinecap=\"round\" className=\"stroke-foreground/70\" {...draw(0.6, reduce)} />\n            <motion.path d=\"M72.5 44.5l7 7M79.5 44.5l-7 7\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"stroke-muted-foreground\" {...draw(0.75, reduce)} />\n          </motion.g>\n        </>\n      )}\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 EmptyState                                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface EmptyStateProps {\n  title: ReactNode;\n  description?: ReactNode;\n  /**\n   * Icon or illustration slot. Defaults to `<EmptyStateIllustration />`;\n   * pass `null` for none.\n   */\n  icon?: ReactNode;\n  /** Primary call to action (e.g. a library `Button`). */\n  action?: ReactNode;\n  /** Secondary call to action, rendered after the primary. */\n  secondaryAction?: ReactNode;\n  size?: EmptyStateSize;\n  /** Dashed border container, for empty regions inside a page. */\n  bordered?: boolean;\n  /** Heading element for the title. Default `h3`. */\n  titleAs?: \"h2\" | \"h3\" | \"h4\" | \"p\";\n  /** Extra content under the actions (links, hints). */\n  children?: ReactNode;\n  className?: string;\n}\n\nconst SIZE: Record<\n  EmptyStateSize,\n  { root: string; title: string; description: string; art: number }\n> = {\n  sm: { root: \"gap-3 px-4 py-6\", title: \"text-sm\", description: \"text-xs max-w-[32ch]\", art: 88 },\n  md: { root: \"gap-4 px-6 py-10\", title: \"text-base\", description: \"text-sm max-w-[38ch]\", art: 120 },\n  lg: { root: \"gap-5 px-8 py-14\", title: \"text-lg\", description: \"text-base max-w-[44ch]\", art: 152 },\n};\n\n/**\n * Empty-state block: illustration, title, description and actions that\n * stagger in when scrolled into view (opacity + small rise; opacity only under\n * reduced motion).\n */\nexport function EmptyState({\n  title,\n  description,\n  icon,\n  action,\n  secondaryAction,\n  size = \"md\",\n  bordered = false,\n  titleAs: Title = \"h3\",\n  children,\n  className,\n}: EmptyStateProps) {\n  const reduce = useReducedMotion();\n  const s = SIZE[size];\n\n  const container: Variants = {\n    hidden: {},\n    show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },\n  };\n  const item: Variants = {\n    hidden: { opacity: 0, y: reduce ? 0 : 8 },\n    show: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT } },\n  };\n\n  const art = icon === undefined ? <EmptyStateIllustration size={s.art} /> : icon;\n\n  return (\n    <motion.div\n      variants={container}\n      initial=\"hidden\"\n      whileInView=\"show\"\n      viewport={{ once: true, amount: 0.4 }}\n      className={cn(\n        \"flex w-full flex-col items-center text-center\",\n        s.root,\n        bordered && \"rounded-2xl border border-dashed border-border-strong\",\n        className,\n      )}\n    >\n      {art != null ? (\n        <motion.div variants={item} className=\"flex items-center justify-center text-muted-foreground\">\n          {art}\n        </motion.div>\n      ) : null}\n      <div className=\"flex flex-col items-center gap-1.5\">\n        <motion.div variants={item}>\n          <Title className={cn(\"font-semibold tracking-tight text-foreground\", s.title)}>\n            {title}\n          </Title>\n        </motion.div>\n        {description != null ? (\n          <motion.p variants={item} className={cn(\"text-muted-foreground text-balance\", s.description)}>\n            {description}\n          </motion.p>\n        ) : null}\n      </div>\n      {action != null || secondaryAction != null ? (\n        <motion.div variants={item} className=\"flex flex-wrap items-center justify-center gap-2\">\n          {action}\n          {secondaryAction}\n        </motion.div>\n      ) : null}\n      {children != null ? <motion.div variants={item}>{children}</motion.div> : null}\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/inline-edit.tsx","type":"registry:component","target":"@components/motion/inline-edit.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { Loader } from \"@/components/motion/loader\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InlineEditClassNames = {\n  root?: string;\n  display?: string;\n  input?: string;\n  placeholder?: string;\n  errorMessage?: string;\n};\n\nexport interface InlineEditProps {\n  /** Accessible name of the field (\"Name\", \"Bio\"); also used in the edit hint. */\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  /**\n   * Commit handler. Return a promise to show a pending spinner; the shown value\n   * rolls back (with a shake and the rejection message) if it rejects.\n   * Controlled callers update `value` here.\n   */\n  onSave?: (next: string) => unknown;\n  onCancel?: () => void;\n  onEditingChange?: (editing: boolean) => void;\n  /** Return an error message to block the save and keep the editor open. */\n  validate?: (next: string) => string | null | undefined | false;\n  /** Edit in an auto-growing textarea; Enter saves, Shift+Enter adds a line. */\n  multiline?: boolean;\n  placeholder?: string;\n  maxLength?: number;\n  /** Select the whole text when the editor opens (default) or put the caret at the end. */\n  selectOnEdit?: boolean;\n  disabled?: boolean;\n  className?: string;\n  classNames?: InlineEditClassNames;\n}\n\ntype Status = \"idle\" | \"pending\" | \"saved\";\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\nconst SAVED_FLASH_MS = 1400;\n\nfunction messageOf(reason: unknown) {\n  if (reason instanceof Error && reason.message) return reason.message;\n  if (typeof reason === \"string\" && reason) return reason;\n  return \"Couldn't save. Try again.\";\n}\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    typeof (value as PromiseLike<unknown>).then === \"function\"\n  );\n}\n\nexport function InlineEdit({\n  label,\n  value: valueProp,\n  defaultValue,\n  onSave,\n  onCancel,\n  onEditingChange,\n  validate,\n  multiline = false,\n  placeholder = \"Empty\",\n  maxLength,\n  selectOnEdit = true,\n  disabled,\n  className,\n  classNames,\n}: InlineEditProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [editing, setEditing] = useState(false);\n  const [draft, setDraft] = useState(value);\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [pendingValue, setPendingValue] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n\n  // Refs gate commit/cancel so the blur fired while the editor unmounts can't\n  // save a second time.\n  const editingRef = useRef(false);\n  const restoreFocusRef = useRef(false);\n  const saveIdRef = useRef(0);\n  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const mountedRef = useRef(true);\n\n  const boxRef = useRef<HTMLDivElement>(null);\n  const displayRef = useRef<HTMLButtonElement>(null);\n  const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    mountedRef.current = true;\n    return () => {\n      mountedRef.current = false;\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n    };\n  }, []);\n\n  const shake = useCallback(() => {\n    if (!boxRef.current || reduce) return;\n    animate(boxRef.current, SHAKE, { duration: 0.45 });\n  }, [reduce]);\n\n  const setEditingState = (next: boolean) => {\n    editingRef.current = next;\n    setEditing(next);\n    onEditingChange?.(next);\n  };\n\n  // Focus the editor as it opens; return focus to the text after a\n  // keyboard commit or cancel (not after blur — that would steal focus).\n  useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current;\n      if (!field) return;\n      field.focus();\n      if (selectOnEdit) field.select();\n      else field.setSelectionRange(field.value.length, field.value.length);\n    } else if (restoreFocusRef.current) {\n      restoreFocusRef.current = false;\n      displayRef.current?.focus();\n    }\n  }, [editing, selectOnEdit]);\n\n  const startEditing = () => {\n    if (disabled || editingRef.current || status === \"pending\") return;\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    setStatus(\"idle\");\n    setDraft(value);\n    setError(null);\n    setEditingState(true);\n  };\n\n  const cancel = () => {\n    if (!editingRef.current) return;\n    restoreFocusRef.current = true;\n    setDraft(value);\n    setError(null);\n    setEditingState(false);\n    onCancel?.();\n  };\n\n  const flashSaved = () => {\n    setStatus(\"saved\");\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    flashTimer.current = setTimeout(() => {\n      if (mountedRef.current) setStatus(\"idle\");\n    }, SAVED_FLASH_MS);\n  };\n\n  const commit = (via: \"key\" | \"blur\") => {\n    if (!editingRef.current) return;\n    const next = draft;\n    if (next === value) {\n      if (via === \"key\") restoreFocusRef.current = true;\n      setError(null);\n      setEditingState(false);\n      return;\n    }\n    const invalid = validate?.(next);\n    if (invalid) {\n      setError(invalid);\n      shake();\n      return;\n    }\n\n    if (via === \"key\") restoreFocusRef.current = true;\n    setError(null);\n    setEditingState(false);\n\n    const result = onSave?.(next);\n    if (!isThenable(result)) {\n      if (!controlled) setInternal(next);\n      flashSaved();\n      return;\n    }\n\n    const saveId = ++saveIdRef.current;\n    setPendingValue(next);\n    setStatus(\"pending\");\n    Promise.resolve(result).then(\n      () => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        if (!controlled) setInternal(next);\n        flashSaved();\n      },\n      (reason: unknown) => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        setStatus(\"idle\");\n        setError(messageOf(reason));\n        shake();\n      },\n    );\n  };\n\n  const onFieldKeyDown = (\n    event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ) => {\n    if (event.nativeEvent.isComposing) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      cancel();\n    } else if (event.key === \"Enter\" && !(multiline && event.shiftKey)) {\n      event.preventDefault();\n      commit(\"key\");\n    }\n  };\n\n  const shown = status === \"pending\" ? pendingValue : value;\n  const errorId = `${id}-error`;\n  // Display and editor share this box model exactly, so swapping them never\n  // moves the text. The -mx-2 hangs the padding outside so text aligns with\n  // surrounding content.\n  const textBox = cn(\n    \"w-full rounded-lg px-2 py-1 pr-8 text-left\",\n    multiline ? \"whitespace-pre-wrap break-words\" : \"\",\n  );\n  const iconSlot = cn(\n    \"pointer-events-none absolute right-2 z-10 flex size-4 items-center justify-center\",\n    multiline ? \"top-[calc(0.25rem+0.5lh)] -translate-y-1/2\" : \"top-1/2 -translate-y-1/2\",\n  );\n\n  const motionIn = reduce\n    ? { opacity: 1 }\n    : { opacity: 1, scale: 1 };\n  const motionOut = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, scale: 0.6 };\n\n  return (\n    <div\n      data-state={editing ? \"editing\" : status}\n      className={cn(\"relative -mx-2\", className, classNames?.root)}\n    >\n      <div ref={boxRef} className=\"group relative\">\n        {/* Field surface: fades and settles in under the text while editing. */}\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-0 rounded-lg border bg-background ring-2\",\n            error\n              ? \"border-destructive ring-destructive/25\"\n              : \"border-foreground/30 ring-ring/40\",\n          )}\n          initial={false}\n          animate={\n            editing\n              ? motionIn\n              : reduce\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.98 }\n          }\n          transition={{ duration: editing ? 0.2 : 0.12, ease: EASE_OUT }}\n        />\n\n        {editing ? (\n          multiline ? (\n            <div className=\"relative grid\">\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  textBox,\n                  \"invisible [grid-area:1/1]\",\n                  classNames?.input,\n                )}\n              >\n                {`${draft} `}\n              </span>\n              <textarea\n                ref={fieldRef}\n                aria-label={label}\n                aria-invalid={error ? true : undefined}\n                aria-describedby={error ? errorId : undefined}\n                rows={1}\n                value={draft}\n                maxLength={maxLength}\n                placeholder={placeholder}\n                onChange={(e) => {\n                  setDraft(e.target.value);\n                  if (error) setError(null);\n                }}\n                onKeyDown={onFieldKeyDown}\n                onBlur={() => commit(\"blur\")}\n                className={cn(\n                  textBox,\n                  \"relative resize-none overflow-hidden bg-transparent text-foreground caret-foreground outline-none [grid-area:1/1] placeholder:text-muted-foreground/60\",\n                  classNames?.input,\n                )}\n              />\n            </div>\n          ) : (\n            <input\n              ref={fieldRef}\n              aria-label={label}\n              aria-invalid={error ? true : undefined}\n              aria-describedby={error ? errorId : undefined}\n              value={draft}\n              maxLength={maxLength}\n              placeholder={placeholder}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (error) setError(null);\n              }}\n              onKeyDown={onFieldKeyDown}\n              onBlur={() => commit(\"blur\")}\n              className={cn(\n                textBox,\n                \"relative block bg-transparent text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60\",\n                classNames?.input,\n              )}\n            />\n          )\n        ) : (\n          <button\n            ref={displayRef}\n            type=\"button\"\n            disabled={disabled}\n            aria-describedby={error ? errorId : undefined}\n            onClick={startEditing}\n            onKeyDown={(event) => {\n              if (event.key === \"F2\") {\n                event.preventDefault();\n                startEditing();\n              }\n            }}\n            className={cn(\n              textBox,\n              \"relative block cursor-text transition-[background-color,opacity] duration-150\",\n              \"outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50\",\n              !multiline && \"truncate\",\n              status === \"pending\" && \"opacity-70\",\n              disabled && \"cursor-not-allowed hover:bg-transparent\",\n              classNames?.display,\n            )}\n          >\n            {shown ? (\n              shown\n            ) : (\n              <span\n                className={cn(\n                  \"text-muted-foreground/70 italic\",\n                  classNames?.placeholder,\n                )}\n              >\n                {placeholder}\n              </span>\n            )}\n            <span className=\"sr-only\">, edit {label}</span>\n          </button>\n        )}\n\n        <span className={iconSlot}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {status === \"pending\" ? (\n              <motion.span\n                key=\"pending\"\n                className=\"flex\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={motionOut}\n                transition={SPRING_SWAP}\n              >\n                <Loader\n                  variant=\"spinner\"\n                  size={14}\n                  label=\"Saving\"\n                  className=\"text-muted-foreground\"\n                />\n              </motion.span>\n            ) : status === \"saved\" ? (\n              <motion.span\n                key=\"saved\"\n                className=\"flex size-4 items-center justify-center rounded-full bg-success text-background\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={{ opacity: 0 }}\n                transition={SPRING_PRESS}\n              >\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n                  <motion.path\n                    d=\"M5 12.5l4.5 4.5L19 7.5\"\n                    stroke=\"currentColor\"\n                    strokeWidth={3}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}\n                  />\n                </svg>\n              </motion.span>\n            ) : !editing && !disabled ? (\n              <motion.span\n                key=\"pencil\"\n                className=\"flex text-muted-foreground\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.15 }}\n              >\n                <span className=\"flex opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100\">\n                  <svg\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    className=\"size-3.5\"\n                    aria-hidden=\"true\"\n                  >\n                    <path d=\"M12 20h9\" />\n                    <path d=\"M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z\" />\n                  </svg>\n                </span>\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"pending\" ? \"Saving\" : status === \"saved\" ? \"Saved\" : \"\"}\n      </span>\n\n      <AnimatePresence initial={false}>\n        {error ? (\n          <motion.p\n            id={errorId}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-2 pt-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {error}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/scroll-area.tsx","type":"registry:component","target":"@components/motion/scroll-area.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// ScrollArea keeps the browser's own scrolling (wheel, touch momentum,\n// keyboard, scroll anchoring, find-in-page) and only replaces the *painted*\n// scrollbar: the native bar is hidden and an overlay thumb is drawn on top.\n// The thumb is a visual affordance (aria-hidden); keyboard and assistive tech\n// scroll the focusable viewport itself.\n\nexport type ScrollAreaOrientation = \"vertical\" | \"horizontal\" | \"both\";\n/** `hover`: visible while hovered or scrolling. `scroll`: only while scrolling. `always`: whenever content overflows. */\nexport type ScrollAreaType = \"hover\" | \"scroll\" | \"always\";\n\nexport interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, \"dir\"> {\n  children?: ReactNode;\n  orientation?: ScrollAreaOrientation;\n  type?: ScrollAreaType;\n  /** Milliseconds of idle before the thumb fades out. */\n  scrollHideDelay?: number;\n  /** Fade the content out at edges that have more content beyond them. */\n  fadeEdges?: boolean;\n  /** Maximum fade length in px; it grows with the distance from the edge. */\n  fadeSize?: number;\n  /** Class for the scrolling viewport (padding usually belongs here). */\n  viewportClassName?: string;\n  /** Ref to the native scroll container, for `scrollTo` or virtualizers. */\n  viewportRef?: Ref<HTMLElement | null>;\n  /** Accessible name for the focusable scroll region. */\n  \"aria-label\"?: string;\n}\n\nconst MIN_THUMB = 24;\nconst TRACK_INSET = 2;\n/** Fraction of the visible size a track click pages by (keeps a little context). */\nconst PAGE_FACTOR = 0.9;\n\ntype Axis = \"x\" | \"y\";\n\nexport function ScrollArea({\n  children,\n  orientation = \"vertical\",\n  type = \"hover\",\n  scrollHideDelay = 700,\n  fadeEdges = true,\n  fadeSize = 28,\n  className,\n  viewportClassName,\n  viewportRef,\n  \"aria-label\": ariaLabel,\n  onPointerEnter,\n  onPointerLeave,\n  ...rest\n}: ScrollAreaProps) {\n  const reduce = useReducedMotion();\n  const viewport = useRef<HTMLElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const hovering = useRef(false);\n  const dragging = useRef<{\n    axis: Axis;\n    pointer: number;\n    scroll: number;\n    ratio: number;\n  } | null>(null);\n\n  const [overflow, setOverflow] = useState({ x: false, y: false });\n  const [visible, setVisible] = useState(false);\n\n  useImperativeHandle(viewportRef, () => viewport.current as HTMLElement, []);\n\n  const allowX = orientation !== \"vertical\";\n  const allowY = orientation !== \"horizontal\";\n\n  // Geometry is written straight to the DOM on every scroll frame so a long\n  // list never re-renders React while scrolling.\n  const update = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = el;\n    const hasY = allowY && scrollHeight - clientHeight > 1;\n    const hasX = allowX && scrollWidth - clientWidth > 1;\n    setOverflow((prev) => (prev.x === hasX && prev.y === hasY ? prev : { x: hasX, y: hasY }));\n\n    const place = (axis: Axis) => {\n      const thumb = thumbs.current[axis];\n      const track = tracks.current[axis];\n      if (!thumb || !track) return;\n      const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n      const view = axis === \"y\" ? clientHeight : clientWidth;\n      const total = axis === \"y\" ? scrollHeight : scrollWidth;\n      const pos = axis === \"y\" ? scrollTop : scrollLeft;\n      const len = Math.max(MIN_THUMB, Math.min(trackLen, (view / total) * trackLen));\n      const max = total - view;\n      const offset = max > 0 ? (pos / max) * (trackLen - len) : 0;\n      // The thumb's length is set (not animated) whenever content resizes;\n      // its motion along the track is a transform.\n      if (axis === \"y\") {\n        thumb.style.height = `${len}px`;\n        thumb.style.transform = `translate3d(0, ${offset}px, 0)`;\n      } else {\n        thumb.style.width = `${len}px`;\n        thumb.style.transform = `translate3d(${offset}px, 0, 0)`;\n      }\n    };\n    if (hasY) place(\"y\");\n    if (hasX) place(\"x\");\n\n    if (fadeEdges) {\n      // Fade length tracks the distance to each edge, so the mask eases in\n      // and out as you leave or reach an end instead of popping.\n      const top = hasY ? Math.min(fadeSize, scrollTop) : 0;\n      const bottom = hasY ? Math.min(fadeSize, scrollHeight - clientHeight - scrollTop) : 0;\n      const left = hasX ? Math.min(fadeSize, scrollLeft) : 0;\n      const right = hasX ? Math.min(fadeSize, scrollWidth - clientWidth - scrollLeft) : 0;\n      el.style.setProperty(\"--sa-top\", `${Math.max(0, top)}px`);\n      el.style.setProperty(\"--sa-bottom\", `${Math.max(0, bottom)}px`);\n      el.style.setProperty(\"--sa-left\", `${Math.max(0, left)}px`);\n      el.style.setProperty(\"--sa-right\", `${Math.max(0, right)}px`);\n    }\n  }, [allowX, allowY, fadeEdges, fadeSize]);\n\n  const scheduleHide = useCallback(() => {\n    clearTimeout(hideTimer.current);\n    hideTimer.current = setTimeout(() => {\n      if (dragging.current) return;\n      if (type === \"hover\" && hovering.current) return;\n      setVisible(false);\n    }, scrollHideDelay);\n  }, [scrollHideDelay, type]);\n\n  const reveal = useCallback(() => {\n    setVisible(true);\n    scheduleHide();\n  }, [scheduleHide]);\n\n  useEffect(() => {\n    const el = viewport.current;\n    const inner = content.current;\n    if (!el || !inner) return;\n    update();\n    const observer = new ResizeObserver(() => update());\n    observer.observe(el);\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, [update]);\n\n  // Bars mount only once overflow is known; place their thumbs right after.\n  useEffect(() => {\n    if (overflow.x || overflow.y) update();\n  }, [overflow.x, overflow.y, update]);\n\n  useEffect(() => () => clearTimeout(hideTimer.current), []);\n\n  const onScroll = () => {\n    update();\n    if (type !== \"always\") reveal();\n  };\n\n  const startDrag = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const track = tracks.current[axis];\n    const thumb = thumbs.current[axis];\n    if (!el || !track || !thumb || event.button !== 0) return;\n    event.preventDefault();\n    event.stopPropagation();\n    const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n    const thumbLen = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n    const max = axis === \"y\" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;\n    dragging.current = {\n      axis,\n      pointer: axis === \"y\" ? event.clientY : event.clientX,\n      scroll: axis === \"y\" ? el.scrollTop : el.scrollLeft,\n      ratio: trackLen - thumbLen > 0 ? max / (trackLen - thumbLen) : 0,\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setVisible(true);\n    clearTimeout(hideTimer.current);\n  };\n\n  const onDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragging.current;\n    const el = viewport.current;\n    if (!drag || !el) return;\n    const delta = (drag.axis === \"y\" ? event.clientY : event.clientX) - drag.pointer;\n    if (drag.axis === \"y\") el.scrollTop = drag.scroll + delta * drag.ratio;\n    else el.scrollLeft = drag.scroll + delta * drag.ratio;\n  };\n\n  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!dragging.current) return;\n    dragging.current = null;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    if (type !== \"always\") scheduleHide();\n  };\n\n  // Clicking the empty track pages one viewport toward the click, like a\n  // native scrollbar track.\n  const pageTrack = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const thumb = thumbs.current[axis];\n    if (!el || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n    event.preventDefault();\n    const rect = thumb.getBoundingClientRect();\n    const before = axis === \"y\" ? event.clientY < rect.top : event.clientX < rect.left;\n    const page = (axis === \"y\" ? el.clientHeight : el.clientWidth) * PAGE_FACTOR;\n    const amount = before ? -page : page;\n    el.scrollBy({\n      top: axis === \"y\" ? amount : 0,\n      left: axis === \"x\" ? amount : 0,\n      behavior: reduce ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const fade = \"linear-gradient(to bottom, transparent 0, black var(--sa-top), black calc(100% - var(--sa-bottom)), transparent 100%)\";\n  const fadeX = \"linear-gradient(to right, transparent 0, black var(--sa-left), black calc(100% - var(--sa-right)), transparent 100%)\";\n  const mask = fadeEdges\n    ? allowX && allowY\n      ? `${fade}, ${fadeX}`\n      : allowX\n        ? fadeX\n        : fade\n    : undefined;\n  const viewportStyle: CSSProperties | undefined = mask\n    ? ({\n        \"--sa-top\": \"0px\",\n        \"--sa-bottom\": \"0px\",\n        \"--sa-left\": \"0px\",\n        \"--sa-right\": \"0px\",\n        maskImage: mask,\n        WebkitMaskImage: mask,\n        maskComposite: allowX && allowY ? \"intersect\" : undefined,\n        WebkitMaskComposite: allowX && allowY ? \"source-in\" : undefined,\n      } as CSSProperties)\n    : undefined;\n\n  const shown = type === \"always\" || visible;\n  const both = overflow.x && overflow.y;\n\n  const renderBar = (axis: Axis) => (\n    <motion.div\n      aria-hidden\n      ref={(node: HTMLDivElement | null) => {\n        tracks.current[axis] = node;\n      }}\n      data-orientation={axis === \"y\" ? \"vertical\" : \"horizontal\"}\n      initial={false}\n      animate={{ opacity: shown ? 1 : 0 }}\n      transition={{ duration: shown ? 0.15 : 0.3, ease: EASE_OUT }}\n      onPointerDown={pageTrack(axis)}\n      className={cn(\n        \"group/scroll-track absolute z-10 flex touch-none select-none\",\n        !shown && \"pointer-events-none\",\n        axis === \"y\" ? \"w-2.5 justify-center\" : \"h-2.5 flex-col justify-center\",\n      )}\n      style={\n        axis === \"y\"\n          ? { top: TRACK_INSET, right: TRACK_INSET, bottom: both ? 12 : TRACK_INSET }\n          : { left: TRACK_INSET, bottom: TRACK_INSET, right: both ? 12 : TRACK_INSET }\n      }\n    >\n      <div\n        ref={(node) => {\n          thumbs.current[axis] = node;\n        }}\n        onPointerDown={startDrag(axis)}\n        onPointerMove={onDragMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className={cn(\n          \"absolute rounded-full bg-foreground/25 transition-colors hover:bg-foreground/45 active:bg-foreground/55 will-change-transform\",\n          axis === \"y\" ? \"top-0 w-1.5 group-hover/scroll-track:w-2\" : \"left-0 h-1.5 group-hover/scroll-track:h-2\",\n        )}\n      />\n    </motion.div>\n  );\n\n  return (\n    <div\n      className={cn(\"relative overflow-hidden\", className)}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        hovering.current = true;\n        if (type === \"hover\") {\n          setVisible(true);\n          clearTimeout(hideTimer.current);\n        }\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        hovering.current = false;\n        if (type === \"hover\") scheduleHide();\n      }}\n      {...rest}\n    >\n      {/* The viewport is focusable (tabIndex 0) so keyboard users can scroll\n          it (WCAG 2.1.1); pass `aria-label` to name the region. */}\n      <section\n        ref={viewport}\n        aria-label={ariaLabel}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable for keyboard scrolling (WCAG 2.1.1).\n        tabIndex={0}\n        onScroll={onScroll}\n        style={viewportStyle}\n        className={cn(\n          \"size-full outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          \"focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n          allowY ? \"overflow-y-auto\" : \"overflow-y-hidden\",\n          allowX ? \"overflow-x-auto\" : \"overflow-x-hidden\",\n          viewportClassName,\n        )}\n      >\n        {/* Horizontal content is laid out at its own width so the observer\n            sees it grow; vertical content keeps the viewport's width. */}\n        <div ref={content} className={cn(allowX ? \"w-max min-w-full\" : \"min-w-full\")}>\n          {children}\n        </div>\n      </section>\n      {overflow.y ? renderBar(\"y\") : null}\n      {overflow.x ? renderBar(\"x\") : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/skeleton.tsx","type":"registry:component","target":"@components/motion/skeleton.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SkeletonShape = \"rect\" | \"circle\" | \"text\";\n\nexport interface SkeletonProps {\n  shape?: SkeletonShape;\n  /** CSS width (number = px). Text defaults to 100%, circles to 40px. */\n  width?: number | string;\n  /** CSS height (number = px). Circles default to their width. */\n  height?: number | string;\n  /** Sweep a highlight across the block. Reduced motion swaps it for a pulse. */\n  shimmer?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst SHAPE_CLASS: Record<SkeletonShape, string> = {\n  rect: \"rounded-lg\",\n  circle: \"rounded-full shrink-0\",\n  text: \"h-3.5 rounded-md\",\n};\n\n// Reduced motion: no sweep; the block breathes in opacity only.\nconst PULSE = {\n  animate: { opacity: [1, 0.55, 1] },\n  transition: { duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\n/**\n * A placeholder block. The shimmer is a gradient strip translated across the\n * block (transform only), clipped by the block's own overflow.\n */\nexport function Skeleton({\n  shape = \"rect\",\n  width,\n  height,\n  shimmer = true,\n  className,\n  style,\n}: SkeletonProps) {\n  const reduce = useReducedMotion();\n  const resolvedWidth = width ?? (shape === \"circle\" ? 40 : undefined);\n  const resolvedHeight = height ?? (shape === \"circle\" ? resolvedWidth : undefined);\n\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative overflow-hidden bg-muted\",\n        SHAPE_CLASS[shape],\n        shape === \"text\" && \"w-full\",\n        className,\n      )}\n      style={{ width: resolvedWidth, height: resolvedHeight, ...style }}\n      animate={reduce && shimmer ? PULSE.animate : undefined}\n      transition={reduce && shimmer ? PULSE.transition : undefined}\n    >\n      {shimmer && !reduce ? (\n        <motion.span\n          className=\"absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]\"\n          initial={{ x: \"-100%\" }}\n          animate={{ x: \"100%\" }}\n          transition={{\n            duration: 1.5,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            repeatDelay: 0.3,\n          }}\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\nexport interface SkeletonTextProps {\n  /** Number of lines. */\n  lines?: number;\n  /** Width of the final line, so the block reads like a real paragraph. */\n  lastLineWidth?: number | string;\n  /** Gap between lines (Tailwind class). */\n  gapClassName?: string;\n  lineClassName?: string;\n  shimmer?: boolean;\n  className?: string;\n}\n\n/** A paragraph of text lines with a shorter, natural-looking last line. */\nexport function SkeletonText({\n  lines = 3,\n  lastLineWidth = \"62%\",\n  gapClassName = \"gap-2\",\n  lineClassName,\n  shimmer = true,\n  className,\n}: SkeletonTextProps) {\n  const count = Math.max(1, lines);\n  return (\n    <div aria-hidden=\"true\" className={cn(\"flex w-full flex-col\", gapClassName, className)}>\n      {Array.from({ length: count }, (_, i) => (\n        <Skeleton\n          // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional and never reorder\n          key={i}\n          shape=\"text\"\n          shimmer={shimmer}\n          className={lineClassName}\n          width={count > 1 && i === count - 1 ? lastLineWidth : \"100%\"}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport interface SkeletonSwapProps {\n  /** While true the skeleton shows; flip to false to cross-fade to children. */\n  loading: boolean;\n  /** Placeholder rendered while loading — mirror the real content's shape. */\n  skeleton: ReactNode;\n  /** Real content. May be absent while loading. */\n  children?: ReactNode;\n  /**\n   * Keep the last measured content height while reloading, so a reload never\n   * collapses the box to the skeleton's height. Default true.\n   */\n  keepHeight?: boolean;\n  /** Announced to screen readers while loading. */\n  loadingLabel?: string;\n  className?: string;\n}\n\n/**\n * Cross-fades from a skeleton to real content without layout shift: both\n * layers share one grid cell during the fade (the box takes the taller of the\n * two), and the last measured content height is held as a min-height while a\n * reload shows the skeleton again.\n */\nexport function SkeletonSwap({\n  loading,\n  skeleton,\n  children,\n  keepHeight = true,\n  loadingLabel = \"Loading\",\n  className,\n}: SkeletonSwapProps) {\n  const reduce = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [measured, setMeasured] = useState<number | null>(null);\n\n  useEffect(() => {\n    const node = contentRef.current;\n    if (loading || !node || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([entry]) => {\n      if (entry) setMeasured(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    ro.observe(node);\n    return () => ro.disconnect();\n  }, [loading]);\n\n  return (\n    <div\n      aria-busy={loading}\n      className={cn(\"relative grid\", className)}\n      style={keepHeight && loading && measured != null ? { minHeight: measured } : undefined}\n    >\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {loading ? loadingLabel : \"\"}\n      </span>\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.div\n            key=\"skeleton\"\n            className=\"[grid-area:1/1]\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n          >\n            {skeleton}\n          </motion.div>\n        ) : (\n          <motion.div\n            key=\"content\"\n            ref={contentRef}\n            className=\"[grid-area:1/1]\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}\n            transition={{ duration: 0.28, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/loader.tsx","type":"registry:component","target":"@components/motion/loader.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LoaderVariant =\n  | \"spinner\"\n  | \"dots\"\n  | \"bars\"\n  | \"dot-matrix\"\n  | \"dither\"\n  | \"ascii\"\n  | \"ascii-line\"\n  | \"ascii-braille\"\n  | \"ascii-blocks\"\n  | \"ascii-bounce\"\n  | \"morph\"\n  | \"comet\"\n  | \"scramble\"\n  | \"metaballs\"\n  | \"newton\"\n  | \"helix\"\n  | \"percent\";\n\n// Terminal-style frame sets — the loaders CLI AI agents cycle through.\nconst ASCII_SETS: Record<string, string[]> = {\n  ascii: [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n  \"ascii-line\": [\"|\", \"/\", \"-\", \"\\\\\"],\n  \"ascii-braille\": [\"⣾\", \"⣽\", \"⣻\", \"⢿\", \"⡿\", \"⣟\", \"⣯\", \"⣷\"],\n  \"ascii-blocks\": [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\", \"▇\", \"▆\", \"▅\", \"▄\", \"▃\", \"▂\"],\n  \"ascii-bounce\": [\"⠁\", \"⠂\", \"⠄\", \"⡀\", \"⢀\", \"⠠\", \"⠐\", \"⠈\"],\n};\n\nexport interface LoaderProps {\n  /** Which animation to render. */\n  variant?: LoaderVariant;\n  /** Base square size in px. Everything scales from this. */\n  size?: number;\n  /** Seconds per animation cycle. */\n  speed?: number;\n  /** Accessible label announced to screen readers. */\n  label?: string;\n  className?: string;\n}\n\n// Reduced motion keeps a calm opacity pulse and drops every transform.\nconst REDUCED = {\n  animate: { opacity: [1, 0.4, 1] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nexport function Loader({\n  variant = \"spinner\",\n  size = 32,\n  speed = 1,\n  label = \"Loading\",\n  className,\n}: LoaderProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      role=\"status\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex items-center justify-center text-foreground\",\n        className,\n      )}\n    >\n      {variant === \"spinner\" && <Spinner size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dots\" && <Dots size={size} speed={speed} reduce={reduce} />}\n      {variant === \"bars\" && <Bars size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dot-matrix\" && (\n        <DotMatrix size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"dither\" && <Dither size={size} speed={speed} reduce={reduce} />}\n      {ASCII_SETS[variant] && (\n        <Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"morph\" && <Morph size={size} speed={speed} reduce={reduce} />}\n      {variant === \"comet\" && <Comet size={size} speed={speed} reduce={reduce} />}\n      {variant === \"scramble\" && (\n        <Scramble size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"metaballs\" && (\n        <Metaballs size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"newton\" && <Newton size={size} speed={speed} reduce={reduce} />}\n      {variant === \"helix\" && <Helix size={size} speed={speed} reduce={reduce} />}\n      {variant === \"percent\" && (\n        <Percent size={size} speed={speed} reduce={reduce} />\n      )}\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\ninterface PartProps {\n  size: number;\n  speed: number;\n  reduce: boolean;\n}\n\nfunction Spinner({ size, speed, reduce }: PartProps) {\n  const stroke = Math.max(2, size * 0.09);\n  const r = (size - stroke) / 2;\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      animate={reduce ? REDUCED.animate : { rotate: 360 }}\n      transition={\n        reduce\n          ? REDUCED.transition\n          : { duration: speed, ease: \"linear\", repeat: Infinity }\n      }\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={r}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={stroke}\n      />\n      <path\n        d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={stroke}\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nfunction Dots({ size, speed, reduce }: PartProps) {\n  const dot = size * 0.24;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.14 }}>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: dot, height: dot }}\n          animate={\n            reduce\n              ? { opacity: [0.4, 1, 0.4] }\n              : { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.16,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Ascii({\n  frames,\n  size,\n  speed,\n  reduce,\n}: PartProps & { frames: string[] }) {\n  const [frame, setFrame] = useState(0);\n  useEffect(() => {\n    // Reduced motion slows the cycle rather than stopping it — it's a glyph\n    // swap, not on-screen movement.\n    const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;\n    const id = setInterval(\n      () => setFrame((f) => (f + 1) % frames.length),\n      step,\n    );\n    return () => clearInterval(id);\n  }, [frames.length, speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono leading-none tabular-nums\"\n      style={{ fontSize: size, lineHeight: 1 }}\n    >\n      {frames[frame % frames.length]}\n    </span>\n  );\n}\n\n// Each shape is sampled at the same number of points and emitted as an SVG\n// path with identical command structure, so framer tweens the `d` attribute\n// point-to-point — a real morph, not a snap. (clip-path polygon strings don't\n// interpolate reliably in framer, which left the shapes broken.)\nconst MORPH_POINTS = 24;\n\nfunction ngonRadius(ang: number, n: number, phase = 0) {\n  const seg = (2 * Math.PI) / n;\n  const a = ang - phase;\n  const local = (((a % seg) + seg) % seg) - seg / 2;\n  return Math.cos(Math.PI / n) / Math.cos(local);\n}\n\nfunction morphPath(radiusAt: (ang: number) => number) {\n  const parts: string[] = [];\n  for (let i = 0; i < MORPH_POINTS; i++) {\n    const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;\n    const r = Math.min(1.05, radiusAt(ang));\n    const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);\n    const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);\n    parts.push(`${i === 0 ? \"M\" : \"L\"}${x} ${y}`);\n  }\n  return `${parts.join(\" \")} Z`;\n}\n\nconst MORPH_PATHS = [\n  morphPath(() => 1), // circle\n  morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square\n  morphPath((a) => ngonRadius(a, 3)), // triangle\n  morphPath((a) => ngonRadius(a, 6)), // hexagon\n  morphPath((a) => ngonRadius(a, 4)), // diamond\n];\n\n// Each shape appears twice in a row so it fully forms and HOLDS before the\n// next morph. Even keyframe spacing then alternates hold / morph segments.\nconst MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];\n// Rotation and scale only change across the morph segments, staying put on the\n// holds, so a settled shape sits still.\nconst MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];\nconst MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];\n\nfunction Morph({ size, speed, reduce }: PartProps) {\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 100 100\"\n      role=\"img\"\n      animate={\n        reduce\n          ? { opacity: [1, 0.4, 1] }\n          : { rotate: MORPH_ROT, scale: MORPH_SCALE }\n      }\n      transition={\n        reduce\n          ? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }\n          : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n      }\n    >\n      <title>Loading</title>\n      <motion.path\n        fill=\"currentColor\"\n        d={MORPH_PATHS[0]}\n        animate={reduce ? undefined : { d: MORPH_SEQ }}\n        transition={\n          reduce\n            ? undefined\n            : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nconst COMET_TRAIL = [0, 1, 2, 3, 4, 5];\n\nfunction Comet({ size, speed, reduce }: PartProps) {\n  const head = size * 0.2;\n  const r = size / 2 - head / 2;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      <motion.span\n        className=\"absolute inset-0\"\n        animate={reduce ? REDUCED.animate : { rotate: 360 }}\n        transition={\n          reduce\n            ? REDUCED.transition\n            : { duration: speed, ease: \"linear\", repeat: Infinity }\n        }\n      >\n        {COMET_TRAIL.map((i) => {\n          const scale = 1 - i * 0.13;\n          const sz = head * scale;\n          return (\n            <span\n              key={i}\n              className=\"absolute top-1/2 left-1/2 rounded-full bg-current\"\n              style={{\n                width: sz,\n                height: sz,\n                marginLeft: -sz / 2,\n                marginTop: -sz / 2,\n                opacity: 1 - i * 0.16,\n                transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,\n              }}\n            />\n          );\n        })}\n      </motion.span>\n    </span>\n  );\n}\n\nconst SCRAMBLE_TARGET = \"LOADING\";\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@\";\n\nfunction Scramble({ size, speed, reduce }: PartProps) {\n  const [text, setText] = useState(SCRAMBLE_TARGET);\n  useEffect(() => {\n    if (reduce) {\n      setText(SCRAMBLE_TARGET);\n      return;\n    }\n    let tick = 0;\n    const total = SCRAMBLE_TARGET.length + 4;\n    const id = setInterval(\n      () => {\n        const reveal = tick % total;\n        let s = \"\";\n        for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {\n          s +=\n            i < reveal\n              ? SCRAMBLE_TARGET[i]\n              : SCRAMBLE_GLYPHS[\n                  Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)\n                ];\n        }\n        setText(s);\n        tick++;\n      },\n      (speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,\n    );\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono font-medium tracking-[0.2em] tabular-nums\"\n      style={{ fontSize: size * 0.42 }}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction Metaballs({ size, speed, reduce }: PartProps) {\n  const id = useId().replace(/:/g, \"\");\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 100 100\" role=\"img\">\n      <title>Loading</title>\n      <defs>\n        <filter id={id}>\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"b\" />\n          <feColorMatrix\n            in=\"b\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\"\n          />\n        </filter>\n      </defs>\n      <g filter={`url(#${id})`} fill=\"currentColor\">\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 40 : 30}\n        />\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 60 : 70}\n        />\n      </g>\n    </svg>\n  );\n}\n\nconst NEWTON_BALLS = [0, 1, 2, 3, 4];\n\nfunction Newton({ size, speed, reduce }: PartProps) {\n  const d = size * 0.2;\n  const out = d * 1.1;\n  // Only the end balls move: the left slides out and back on the first half,\n  // then the right on the second half — the impact appears to jump the three\n  // still middle balls. Pure horizontal slide, no swing, no strings.\n  const moves: Record<number, { x: number[]; times: number[] }> = {\n    0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },\n    4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },\n  };\n\n  return (\n    <span className=\"flex items-center justify-center\" style={{ height: d }}>\n      {NEWTON_BALLS.map((i) => {\n        const move = moves[i];\n        return (\n          <motion.span\n            key={i}\n            className=\"rounded-full bg-current\"\n            style={{ width: d, height: d }}\n            animate={reduce || !move ? undefined : { x: move.x }}\n            transition={\n              reduce || !move\n                ? undefined\n                : {\n                    duration: speed * 1.5,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    times: move.times,\n                  }\n            }\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Helix({ size, speed, reduce }: PartProps) {\n  const rows = 7;\n  const dot = size * 0.14;\n  const amp = size * 0.32;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      {Array.from({ length: rows }, (_, r) => {\n        const top = (r / (rows - 1)) * (size - dot);\n        const delay = (r / rows) * speed;\n        return (\n          <span key={`row-${top}`}>\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [amp, -amp, amp],\n                      scale: [1, 0.5, 1],\n                      opacity: [1, 0.45, 1],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [-amp, amp, -amp],\n                      scale: [0.5, 1, 0.5],\n                      opacity: [0.45, 1, 0.45],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Percent({ size, speed, reduce }: PartProps) {\n  const [p, setP] = useState(0);\n  useEffect(() => {\n    const dur = (reduce ? speed * 2 : speed) * 1000;\n    const start = { t: 0 };\n    const tickMs = 40;\n    const id = setInterval(() => {\n      start.t += tickMs;\n      const next = Math.min(100, Math.round((start.t / dur) * 100));\n      setP(next);\n      if (next >= 100) start.t = 0;\n    }, tickMs);\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"flex flex-col items-center\"\n      style={{ gap: size * 0.14, width: size * 1.4 }}\n    >\n      <span\n        className=\"font-mono font-medium tabular-nums\"\n        style={{ fontSize: size * 0.42, lineHeight: 1 }}\n      >\n        {p}%\n      </span>\n      <span\n        className=\"w-full overflow-hidden rounded-full bg-current/15\"\n        style={{ height: Math.max(3, size * 0.1) }}\n      >\n        <span\n          className=\"block h-full rounded-full bg-current\"\n          style={{ width: `${p}%` }}\n        />\n      </span>\n    </span>\n  );\n}\n\nfunction Bars({ size, speed, reduce }: PartProps) {\n  const bar = size * 0.16;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.1, height: size }}>\n      {[0, 1, 2, 3].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: bar, height: size, originY: 1 }}\n          animate={\n            reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.12,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction DotMatrix({ size, speed, reduce }: PartProps) {\n  const n = 3;\n  const gap = size * 0.14;\n  const dot = (size - gap * (n - 1)) / n;\n  const cells = Array.from({ length: n * n }, (_, idx) => idx);\n  return (\n    <span\n      className=\"grid\"\n      style={{\n        gap,\n        gridTemplateColumns: `repeat(${n}, ${dot}px)`,\n      }}\n    >\n      {cells.map((idx) => {\n        const x = idx % n;\n        const y = Math.floor(idx / n);\n        // Diagonal wave: cells light in order of their distance from the corner.\n        const delay = ((x + y) / (2 * (n - 1))) * speed;\n        return (\n          <motion.span\n            key={idx}\n            className=\"rounded-full bg-current\"\n            style={{ width: dot, height: dot }}\n            animate={\n              reduce\n                ? { opacity: [0.3, 1, 0.3] }\n                : { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }\n            }\n            transition={{\n              duration: speed,\n              ease: EASE_IN_OUT,\n              repeat: Infinity,\n              delay,\n            }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells\n// light in this order, so the fill shimmers like a dissolving halftone.\nconst BAYER_4 = [\n  0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,\n];\n\nfunction Dither({ size, speed, reduce }: PartProps) {\n  const n = 4;\n  const gap = Math.max(1, size * 0.05);\n  const cell = (size - gap * (n - 1)) / n;\n  return (\n    <span\n      className=\"grid\"\n      style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}\n    >\n      {BAYER_4.map((order, idx) => (\n        <motion.span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes\n          key={idx}\n          className=\"bg-current\"\n          style={{ width: cell, height: cell }}\n          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: (order / BAYER_4.length) * speed,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n"}]}