{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"artifact-panel","type":"registry:component","title":"Artifact Panel","description":"Side canvas that slides open beside a chat (its width eases so the conversation reflows smoothly) to show a generated artifact, with a version switcher whose label rolls by direction, Preview/Code tabs rendering code through CodeBlock, copy, download and close actions, a shared-layout expand into a fullscreen overlay, cross-fading content between versions and a skeleton while a new version generates.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","shiki","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/artifact-panel.tsx","type":"registry:component","target":"@components/agents/artifact-panel.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/artifact-panel\n\nimport {\n  ChevronLeft,\n  ChevronRight,\n  Code2,\n  Download,\n  Eye,\n  Maximize2,\n  Minimize2,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport {\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { AgentCodeLanguage } from \"@/components/agents/agent-code\";\nimport { CodeBlock } from \"@/components/agents/code-block\";\nimport { CopyButton } from \"@/components/motion/copy-button\";\nimport { Skeleton, SkeletonText } from \"@/components/motion/skeleton\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/motion/tabs\";\nimport { TextShimmer } from \"@/components/motion/text-shimmer\";\nimport { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ArtifactVersion {\n  id: string;\n  /** Short label shown under the title (\"Added dark mode\"). */\n  label?: string;\n  code: string;\n  language?: AgentCodeLanguage;\n  /** Rendered output for the Preview tab. Without it the panel shows code only. */\n  preview?: ReactNode;\n}\n\nexport type ArtifactTab = \"code\" | \"preview\";\n\nexport interface ArtifactPanelProps {\n  title: ReactNode;\n  versions: ArtifactVersion[];\n  /** Used by the code header and the default download. */\n  filename?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Selected version id; defaults to the latest. */\n  version?: string;\n  defaultVersion?: string;\n  onVersionChange?: (id: string) => void;\n  tab?: ArtifactTab;\n  defaultTab?: ArtifactTab;\n  onTabChange?: (tab: ArtifactTab) => void;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** A new version is being written: shows a skeleton in place of the content. */\n  generating?: boolean;\n  /** Replace the default download (a Blob of the code saved as `filename`). */\n  onDownload?: (version: ArtifactVersion) => void;\n  /** Panel width in px when docked beside the chat. Default 400. */\n  width?: number;\n  /** The chat the panel slides in next to. Omit to render the panel alone. */\n  children?: ReactNode;\n  className?: string;\n  panelClassName?: string;\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\nfunction downloadText(text: string, filename: string) {\n  const url = URL.createObjectURL(new Blob([text], { type: \"text/plain\" }));\n  const a = document.createElement(\"a\");\n  a.href = url;\n  a.download = filename;\n  a.click();\n  URL.revokeObjectURL(url);\n}\n\nexport function ArtifactPanel({\n  title,\n  versions,\n  filename = \"artifact.tsx\",\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  version: versionProp,\n  defaultVersion,\n  onVersionChange,\n  tab: tabProp,\n  defaultTab = \"preview\",\n  onTabChange,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  generating = false,\n  onDownload,\n  width = 400,\n  children,\n  className,\n  panelClassName,\n}: ArtifactPanelProps) {\n  const uid = useId();\n  const reduce = useReducedMotion() ?? false;\n  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);\n  const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange);\n  const [tab, setTab] = useControllable<ArtifactTab>(tabProp, defaultTab, onTabChange);\n  const latest = versions[versions.length - 1]?.id ?? \"\";\n  const [versionId, setVersionId] = useControllable<string>(\n    versionProp,\n    defaultVersion ?? \"\",\n    onVersionChange,\n  );\n  // Uncontrolled with no default follows the newest version as they arrive.\n  const index = Math.max(\n    0,\n    versions.findIndex((v) => v.id === (versionId || latest)),\n  );\n  const current = versions[index];\n\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n\n  // Escape leaves fullscreen.\n  useEffect(() => {\n    if (!expanded) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        focusToggle.current = true;\n        setExpanded(false);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [expanded, setExpanded]);\n\n  // Docked and fullscreen surfaces mount fresh; after a user toggle the new\n  // one takes focus on its expand/minimize button.\n  const focusToggle = useRef(false);\n  const toggleExpanded = (next: boolean) => {\n    focusToggle.current = true;\n    setExpanded(next);\n  };\n\n  const effectiveTab: ArtifactTab = current?.preview == null ? \"code\" : tab;\n\n  const body = current ? (\n    <PanelBody\n      uid={uid}\n      title={title}\n      filename={filename}\n      versions={versions}\n      index={index}\n      current={current}\n      tab={effectiveTab}\n      onTab={setTab}\n      onVersion={(i) => {\n        const next = versions[i];\n        if (next) setVersionId(next.id);\n      }}\n      generating={generating}\n      expanded={expanded}\n      onExpand={toggleExpanded}\n      focusRef={focusToggle}\n      onClose={() => {\n        setExpanded(false);\n        setOpen(false);\n      }}\n      onDownload={() =>\n        onDownload ? onDownload(current) : downloadText(current.code, filename)\n      }\n      reduce={reduce}\n    />\n  ) : null;\n\n  const surface = cn(\n    \"flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm\",\n    panelClassName,\n  );\n\n  return (\n    <div className={cn(\"relative flex h-full w-full min-w-0\", className)}>\n      {children != null && <div className=\"min-w-0 flex-1\">{children}</div>}\n      <AnimatePresence initial={false}>\n        {open && (\n          // Width is animated on purpose: the docked slot opening is what\n          // pushes the chat aside, so the chat reflows smoothly instead of\n          // jumping. The panel inside keeps a fixed width and never reflows.\n          <motion.div\n            key=\"slot\"\n            className={cn(\"h-full shrink-0 overflow-hidden\", children == null && \"w-full\")}\n            initial={children == null || reduce ? false : { width: 0 }}\n            animate={children == null ? undefined : { width: width + 12 }}\n            exit={children == null || reduce ? { opacity: 0 } : { width: 0 }}\n            transition={{ duration: 0.32, ease: EASE_DRAWER }}\n          >\n            <div\n              className={cn(\"h-full\", children != null && \"pl-3\")}\n              style={children != null ? { width: width + 12 } : undefined}\n            >\n              {expanded ? (\n                <div className=\"flex h-full items-center justify-center rounded-2xl border border-dashed border-border-strong text-xs text-muted-foreground\">\n                  Open in fullscreen\n                </div>\n              ) : (\n                <motion.aside\n                  layoutId={reduce ? undefined : `${uid}-artifact`}\n                  aria-label=\"Artifact\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}\n                  animate={{ opacity: 1, x: 0 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}\n                  transition={SPRING_PANEL}\n                  className={surface}\n                >\n                  {body}\n                </motion.aside>\n              )}\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {open && expanded && (\n              <motion.div key=\"fullscreen\" className=\"fixed inset-0 z-50 p-4 sm:p-8\">\n                <motion.button\n                  type=\"button\"\n                  aria-label=\"Exit fullscreen\"\n                  tabIndex={-1}\n                  className=\"absolute inset-0 cursor-default bg-background/70 backdrop-blur-sm\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.15 } }}\n                  onClick={() => toggleExpanded(false)}\n                />\n                {/* Shares a layoutId with the docked panel, so the same surface\n                    grows out of the chat instead of a new one appearing. */}\n                <motion.div\n                  layoutId={reduce ? undefined : `${uid}-artifact`}\n                  aria-label=\"Artifact\"\n                  aria-modal=\"true\"\n                  role=\"dialog\"\n                  initial={reduce ? { opacity: 0 } : undefined}\n                  animate={{ opacity: 1 }}\n                  exit={reduce ? { opacity: 0 } : undefined}\n                  transition={SPRING_LAYOUT}\n                  className={cn(surface, \"relative mx-auto max-w-5xl shadow-lg\")}\n                >\n                  {body}\n                </motion.div>\n              </motion.div>\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </div>\n  );\n}\n\nconst LABEL_ROLL: Variants = {\n  enter: (d: number) => ({ opacity: 0, y: d * 10 }),\n  center: { opacity: 1, y: 0 },\n  exit: (d: number) => ({ opacity: 0, y: d * -10 }),\n};\nconst LABEL_FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };\n\ninterface PanelBodyProps {\n  uid: string;\n  title: ReactNode;\n  filename: string;\n  versions: ArtifactVersion[];\n  index: number;\n  current: ArtifactVersion;\n  tab: ArtifactTab;\n  onTab: (tab: ArtifactTab) => void;\n  onVersion: (index: number) => void;\n  generating: boolean;\n  expanded: boolean;\n  onExpand: (expanded: boolean) => void;\n  onClose: () => void;\n  onDownload: () => void;\n  focusRef: { current: boolean };\n  reduce: boolean;\n}\n\nfunction PanelBody({\n  uid,\n  title,\n  filename,\n  versions,\n  index,\n  current,\n  tab,\n  onTab,\n  onVersion,\n  generating,\n  expanded,\n  onExpand,\n  onClose,\n  onDownload,\n  focusRef,\n  reduce,\n}: PanelBodyProps) {\n  const expandRef = useRef<HTMLButtonElement>(null);\n  const prevIndex = useRef(index);\n  const direction = index >= prevIndex.current ? 1 : -1;\n  useEffect(() => {\n    prevIndex.current = index;\n  }, [index]);\n\n  useEffect(() => {\n    if (!focusRef.current) return;\n    focusRef.current = false;\n    expandRef.current?.focus();\n  }, [focusRef]);\n\n  const hasPreview = current.preview != null;\n  const contentKey = generating ? \"generating\" : `${current.id}:${tab}`;\n\n  return (\n    <>\n      <header className=\"flex items-start gap-2 border-b border-border px-4 py-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h2 id={`${uid}-title`} className=\"truncate text-sm font-semibold text-foreground\">\n            {title}\n          </h2>\n          <div className=\"relative h-4 overflow-hidden text-xs text-muted-foreground\">\n            <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n              <motion.p\n                key={generating ? \"gen\" : current.id}\n                custom={direction}\n                variants={reduce ? LABEL_FADE : LABEL_ROLL}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={SPRING_SWAP}\n                className=\"truncate\"\n              >\n                {generating ? (\n                  <TextShimmer>Generating new version…</TextShimmer>\n                ) : (\n                  (current.label ?? `Version ${index + 1}`)\n                )}\n              </motion.p>\n            </AnimatePresence>\n          </div>\n        </div>\n        {versions.length > 1 && (\n          <fieldset\n            aria-label=\"Version\"\n            className=\"flex shrink-0 items-center rounded-lg border border-border bg-muted/50\"\n          >\n            <IconButton\n              label=\"Previous version\"\n              disabled={index === 0 || generating}\n              onClick={() => onVersion(index - 1)}\n            >\n              <ChevronLeft />\n            </IconButton>\n            <span\n              aria-live=\"polite\"\n              className=\"min-w-11 px-0.5 text-center font-mono text-[11px] tabular-nums text-muted-foreground\"\n            >\n              v{index + 1}\n              <span className=\"text-muted-foreground/60\">/{versions.length}</span>\n            </span>\n            <IconButton\n              label=\"Next version\"\n              disabled={index === versions.length - 1 || generating}\n              onClick={() => onVersion(index + 1)}\n            >\n              <ChevronRight />\n            </IconButton>\n          </fieldset>\n        )}\n        <IconButton label=\"Close artifact\" onClick={onClose}>\n          <X />\n        </IconButton>\n      </header>\n\n      <div className=\"flex items-center gap-1 px-3 py-2\">\n        {hasPreview ? (\n          <Tabs\n            value={tab}\n            onValueChange={(v) => onTab(v as ArtifactTab)}\n            variant=\"segment\"\n          >\n            <TabsList className=\"bg-muted\">\n              <TabsTrigger value=\"preview\" className=\"gap-1.5 px-2.5 py-1 text-xs\">\n                <Eye aria-hidden=\"true\" className=\"size-3.5\" />\n                Preview\n              </TabsTrigger>\n              <TabsTrigger value=\"code\" className=\"gap-1.5 px-2.5 py-1 text-xs\">\n                <Code2 aria-hidden=\"true\" className=\"size-3.5\" />\n                Code\n              </TabsTrigger>\n            </TabsList>\n          </Tabs>\n        ) : (\n          <span className=\"px-1 font-mono text-xs text-muted-foreground\">{filename}</span>\n        )}\n        <div className=\"ml-auto flex items-center gap-0.5\">\n          <CopyButton\n            value={current.code}\n            size=\"icon\"\n            variant=\"ghost\"\n            showLabel={false}\n            disabled={generating}\n            aria-label=\"Copy code\"\n          />\n          <IconButton label=\"Download\" disabled={generating} onClick={onDownload}>\n            <Download />\n          </IconButton>\n          <IconButton\n            ref={expandRef}\n            label={expanded ? \"Exit fullscreen\" : \"Expand to fullscreen\"}\n            onClick={() => onExpand(!expanded)}\n          >\n            {expanded ? <Minimize2 /> : <Maximize2 />}\n          </IconButton>\n        </div>\n      </div>\n\n      <div className=\"relative grid min-h-0 flex-1 px-3 pb-3\">\n        <AnimatePresence initial={false}>\n          <motion.div\n            key={contentKey}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.985 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } }}\n            transition={{ duration: 0.24, ease: EASE_OUT }}\n            className=\"min-h-0 overflow-auto rounded-xl [grid-area:1/1] [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n          >\n            {generating ? (\n              <div aria-busy=\"true\" className=\"flex h-full flex-col gap-3 rounded-xl bg-muted/60 p-4\">\n                <span className=\"sr-only\">Generating a new version</span>\n                {tab === \"preview\" && hasPreview ? (\n                  <>\n                    <Skeleton height={28} width=\"45%\" />\n                    <Skeleton height={96} />\n                    <SkeletonText lines={3} />\n                  </>\n                ) : (\n                  <SkeletonText lines={9} lastLineWidth=\"35%\" />\n                )}\n              </div>\n            ) : tab === \"preview\" && hasPreview ? (\n              <div className=\"flex min-h-full items-center justify-center rounded-xl border border-border bg-background p-4\">\n                {current.preview}\n              </div>\n            ) : (\n              <CodeBlock\n                code={current.code}\n                language={current.language ?? \"tsx\"}\n                filename={filename}\n                copyable={false}\n                maxHeight={expanded ? 2000 : 1000}\n                className=\"rounded-xl\"\n              />\n            )}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n    </>\n  );\n}\n\nconst IconButton = ({\n  label,\n  disabled,\n  onClick,\n  children,\n  ref,\n}: {\n  label: string;\n  disabled?: boolean;\n  onClick: () => void;\n  children: ReactNode;\n  ref?: Ref<HTMLButtonElement>;\n}) => (\n  <motion.button\n    ref={ref}\n    type=\"button\"\n    aria-label={label}\n    title={label}\n    disabled={disabled}\n    onClick={onClick}\n    whileTap={disabled ? undefined : { scale: 0.9 }}\n    transition={SPRING_SWAP}\n    className={cn(\n      \"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors [&_svg]:size-3.5\",\n      \"hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n      \"disabled:pointer-events-none disabled:opacity-40\",\n    )}\n  >\n    {children}\n  </motion.button>\n);\n"},{"path":"components/agents/agent-code.tsx","type":"registry:component","target":"@components/agents/agent-code.tsx","content":"\"use client\";\n\nimport {\n  type CSSProperties,\n  Fragment,\n  useEffect,\n  useState,\n} from \"react\";\nimport { createHighlighter, type Highlighter } from \"shiki\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AgentCodeLanguage =\n  | \"bash\"\n  | \"diff\"\n  | \"json\"\n  | \"text\"\n  | \"tsx\"\n  | \"typescript\";\n\nexport interface AgentCodeToken {\n  content: string;\n  offset: number;\n  light?: string;\n  dark?: string;\n}\n\nexport type AgentCodeTokenLines = AgentCodeToken[][];\n\nexport interface AgentCodeProps {\n  code: string;\n  language?: AgentCodeLanguage;\n  className?: string;\n}\n\nexport interface AgentCodeLineProps {\n  code: string;\n  tokens?: AgentCodeToken[];\n  className?: string;\n}\n\nconst LIGHT_THEME = \"github-light-high-contrast\";\nconst DARK_THEME = \"github-dark-high-contrast\";\nlet agentCodeHighlighter: Promise<Highlighter> | null = null;\nconst tokenCache = new Map<string, AgentCodeTokenLines>();\n\nfunction getAgentCodeHighlighter() {\n  if (!agentCodeHighlighter) {\n    agentCodeHighlighter = createHighlighter({\n      themes: [LIGHT_THEME, DARK_THEME],\n      langs: [\"bash\", \"diff\", \"json\", \"tsx\", \"typescript\"],\n    });\n  }\n  return agentCodeHighlighter;\n}\n\nfunction tokenCacheKey(code: string, language: AgentCodeLanguage) {\n  return `${language}\\u0000${code}`;\n}\n\nexport function useAgentCodeTokens(\n  code: string,\n  language: AgentCodeLanguage,\n) {\n  const key = tokenCacheKey(code, language);\n  const cached = tokenCache.get(key);\n  const [result, setResult] = useState<{\n    key: string;\n    code: string;\n    language: AgentCodeLanguage;\n    lines: AgentCodeTokenLines;\n  } | null>(cached ? { key, code, language, lines: cached } : null);\n\n  useEffect(() => {\n    const current = tokenCache.get(key);\n    if (current) {\n      setResult({ key, code, language, lines: current });\n      return;\n    }\n\n    let cancelled = false;\n    getAgentCodeHighlighter().then((highlighter) => {\n      if (cancelled) return;\n      const lines = highlighter\n        .codeToTokensWithThemes(code, {\n          lang: language,\n          themes: {\n            light: LIGHT_THEME,\n            dark: DARK_THEME,\n          },\n        })\n        .map((line) =>\n          line.map((token) => ({\n            content: token.content,\n            offset: token.offset,\n            light: token.variants.light?.color,\n            dark: token.variants.dark?.color,\n          })),\n      );\n      tokenCache.set(key, lines);\n      setResult({ key, code, language, lines });\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [code, key, language]);\n\n  if (result?.key === key) return result.lines;\n  if (result?.language === language && code.startsWith(result.code)) {\n    return result.lines;\n  }\n  return null;\n}\n\nexport function AgentCodeLine({\n  code,\n  tokens,\n  className,\n}: AgentCodeLineProps) {\n  return (\n    <span className={className}>\n      {tokens\n        ? tokens.map((token) => (\n            <span\n              key={`${token.offset}-${token.content}`}\n              style={\n                {\n                  \"--agent-code-light\": token.light ?? \"currentColor\",\n                  \"--agent-code-dark\": token.dark ?? token.light ?? \"currentColor\",\n                } as CSSProperties\n              }\n              className=\"text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]\"\n            >\n              {token.content}\n            </span>\n          ))\n        : code}\n    </span>\n  );\n}\n\nexport function AgentCode({\n  code,\n  language = \"bash\",\n  className,\n}: AgentCodeProps) {\n  const tokens = useAgentCodeTokens(code, language);\n  let offset = 0;\n  const lines = code.split(\"\\n\").map((content) => {\n    const line = { content, offset };\n    offset += content.length + 1;\n    return line;\n  });\n\n  return (\n    <pre\n      className={cn(\n        \"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85\",\n        className,\n      )}\n    >\n      <code>\n        {lines.map((line, index) => (\n          <Fragment key={line.offset}>\n            <AgentCodeLine code={line.content} tokens={tokens?.[index]} />\n            {index < lines.length - 1 ? \"\\n\" : null}\n          </Fragment>\n        ))}\n      </code>\n    </pre>\n  );\n}\n"},{"path":"components/agents/code-block.tsx","type":"registry:component","target":"@components/agents/code-block.tsx","content":"\"use client\";\n\nimport { Check, Copy, FileCode2, LoaderCircle } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type AgentCodeLanguage,\n  AgentCodeLine,\n  useAgentCodeTokens,\n} from \"@/components/agents/agent-code\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CodeBlockStatus = \"streaming\" | \"complete\";\n\nexport interface CodeBlockProps {\n  code: string;\n  language?: AgentCodeLanguage;\n  filename?: ReactNode;\n  status?: CodeBlockStatus;\n  showLineNumbers?: boolean;\n  highlightLines?: number[];\n  maxHeight?: number;\n  wrap?: boolean;\n  copyable?: boolean;\n  onCopy?: () => void | Promise<void>;\n  className?: string;\n}\n\nexport function CodeBlock({\n  code,\n  language = \"typescript\",\n  filename,\n  status = \"complete\",\n  showLineNumbers = true,\n  highlightLines = [],\n  maxHeight = 280,\n  wrap = false,\n  copyable = true,\n  onCopy,\n  className,\n}: CodeBlockProps) {\n  const reduce = useReducedMotion() ?? false;\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const copyTimer = useRef<number | undefined>(undefined);\n  const [copied, setCopied] = useState(false);\n  const streaming = status === \"streaming\";\n  const tokens = useAgentCodeTokens(code, language);\n  const highlighted = useMemo(\n    () => new Set(highlightLines),\n    [highlightLines],\n  );\n  let offset = 0;\n  const lines = code.split(\"\\n\").map((content) => {\n    const line = { content, offset };\n    offset += content.length + 1;\n    return line;\n  });\n\n  useEffect(\n    () => () => {\n      if (copyTimer.current) window.clearTimeout(copyTimer.current);\n    },\n    [],\n  );\n\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport || !streaming) 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  });\n\n  const handleCopy = useCallback(async () => {\n    if (onCopy) await onCopy();\n    else await navigator.clipboard?.writeText(code);\n\n    setCopied(true);\n    if (copyTimer.current) window.clearTimeout(copyTimer.current);\n    copyTimer.current = window.setTimeout(() => setCopied(false), 1600);\n  }, [code, onCopy]);\n\n  return (\n    <div\n      data-state={status}\n      aria-busy={streaming}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl bg-muted/80 text-sm\",\n        className,\n      )}\n    >\n      <div className=\"flex h-10 items-center gap-2.5 px-3\">\n        <FileCode2\n          aria-hidden=\"true\"\n          className=\"size-3.5 shrink-0 text-muted-foreground/70\"\n        />\n        {filename ? (\n          <span className=\"min-w-0 truncate font-mono text-xs text-foreground/80\">\n            {filename}\n          </span>\n        ) : null}\n        <span className=\"text-[10px] font-medium uppercase tracking-wide text-muted-foreground/55\">\n          {language}\n        </span>\n        <span\n          className={cn(\n            \"ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] font-medium\",\n            streaming\n              ? \"text-blue-600 dark:text-blue-400\"\n              : \"text-emerald-600 dark:text-emerald-400\",\n          )}\n        >\n          {streaming ? (\n            <LoaderCircle className={cn(\"size-3\", !reduce && \"animate-spin\")} />\n          ) : (\n            <Check className=\"size-3\" />\n          )}\n          {streaming ? \"Writing\" : \"Ready\"}\n        </span>\n        {copyable || onCopy ? (\n          <motion.button\n            type=\"button\"\n            aria-label={copied ? \"Copied\" : \"Copy code\"}\n            title={copied ? \"Copied\" : \"Copy code\"}\n            onClick={handleCopy}\n            whileTap={reduce ? undefined : { scale: 0.9 }}\n            transition={SPRING_PRESS}\n            className=\"grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-background/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {copied ? (\n              <Check className=\"size-3.5\" />\n            ) : (\n              <Copy className=\"size-3.5\" />\n            )}\n          </motion.button>\n        ) : null}\n      </div>\n\n      <div\n        ref={viewportRef}\n        role={streaming ? \"log\" : undefined}\n        aria-live={streaming ? \"polite\" : undefined}\n        className=\"scrollbar-hide overflow-auto border-t border-foreground/[0.06] py-2\"\n        style={{ maxHeight }}\n      >\n        <pre className=\"m-0 min-w-max font-mono text-xs leading-5 text-foreground/85\">\n          <code>\n            {lines.map((line, index) => {\n              const lineNumber = index + 1;\n              return (\n                <span\n                  key={line.offset}\n                  className={cn(\n                    \"grid min-h-5\",\n                    showLineNumbers\n                      ? \"grid-cols-[2.75rem_minmax(0,1fr)]\"\n                      : \"grid-cols-1\",\n                    highlighted.has(lineNumber) && \"bg-blue-500/[0.07]\",\n                  )}\n                >\n                  {showLineNumbers ? (\n                    <span className=\"select-none pr-3 text-right tabular-nums text-muted-foreground/35\">\n                      {lineNumber}\n                    </span>\n                  ) : null}\n                  <AgentCodeLine\n                    code={line.content}\n                    tokens={tokens?.[index]}\n                    className={cn(\n                      \"pr-4\",\n                      showLineNumbers ? \"pl-1\" : \"pl-4\",\n                      wrap ? \"whitespace-pre-wrap break-words\" : \"whitespace-pre\",\n                    )}\n                  />\n                </span>\n              );\n            })}\n          </code>\n        </pre>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/copy-button.tsx","type":"registry:component","target":"@components/motion/copy-button.tsx","content":"\"use client\";\n\nimport { Check, Copy, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { type ReactNode, useCallback, useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CopyButtonStatus = \"idle\" | \"copied\" | \"error\";\nexport type CopyButtonVariant = \"secondary\" | \"ghost\" | \"outline\" | \"primary\";\nexport type CopyButtonSize = \"sm\" | \"md\" | \"icon\";\n\nexport interface CopyButtonLabels {\n  idle: string;\n  copied: string;\n  error: string;\n}\n\nexport interface CopyButtonProps\n  extends Omit<\n    HTMLMotionProps<\"button\">,\n    \"value\" | \"children\" | \"onCopy\" | \"onError\" | \"onClick\"\n  > {\n  /** Text to copy, or a (possibly async) getter evaluated on click. */\n  value: string | (() => string | Promise<string>);\n  /** Show a text label next to the icon that swaps with the status. */\n  showLabel?: boolean;\n  /** Label text per status. Also used for the accessible name and announcement. */\n  labels?: Partial<CopyButtonLabels>;\n  /** How long the copied/error state lingers before resetting, in ms. */\n  timeout?: number;\n  variant?: CopyButtonVariant;\n  size?: CopyButtonSize;\n  /** Override the idle / copied / error icons. */\n  icons?: Partial<Record<CopyButtonStatus, ReactNode>>;\n  onCopy?: (text: string) => void;\n  onCopyError?: (error: unknown) => void;\n  onStatusChange?: (status: CopyButtonStatus) => void;\n}\n\nconst DEFAULT_LABELS: CopyButtonLabels = {\n  idle: \"Copy\",\n  copied: \"Copied\",\n  error: \"Failed\",\n};\n\nconst VARIANT_CLASS: Record<CopyButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary:\n    \"border border-border bg-card text-muted-foreground hover:text-foreground\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n  outline:\n    \"border border-border bg-transparent text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<CopyButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  icon: \"size-8 rounded-lg\",\n};\n\nconst SWAP_BLUR = \"blur(6px)\";\n\nconst ICON_VARIANTS: Variants = {\n  initial: { opacity: 0, scale: 0.3, filter: SWAP_BLUR },\n  animate: {\n    opacity: 1,\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    scale: 0.3,\n    filter: SWAP_BLUR,\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst LABEL_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"60%\", filter: SWAP_BLUR },\n  animate: {\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    y: \"-60%\",\n    filter: SWAP_BLUR,\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst FADE_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/** `document.execCommand(\"copy\")` fallback for insecure contexts and old browsers. */\nfunction legacyCopy(text: string) {\n  const textarea = document.createElement(\"textarea\");\n  textarea.value = text;\n  textarea.setAttribute(\"readonly\", \"\");\n  textarea.style.position = \"fixed\";\n  textarea.style.top = \"0\";\n  textarea.style.left = \"0\";\n  textarea.style.opacity = \"0\";\n  textarea.style.pointerEvents = \"none\";\n  document.body.appendChild(textarea);\n  const selection = document.getSelection();\n  const previous =\n    selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;\n  textarea.select();\n  let ok = false;\n  try {\n    ok = document.execCommand(\"copy\");\n  } finally {\n    document.body.removeChild(textarea);\n    if (previous && selection) {\n      selection.removeAllRanges();\n      selection.addRange(previous);\n    }\n  }\n  if (!ok) throw new Error(\"Copy command was rejected\");\n}\n\n/** Copy text with the async Clipboard API, falling back to execCommand. */\nexport async function copyToClipboard(text: string) {\n  if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(text);\n      return;\n    } catch {\n      // Permission denied or insecure context — try the legacy path.\n    }\n  }\n  legacyCopy(text);\n}\n\nexport function CopyButton({\n  value,\n  showLabel = false,\n  labels,\n  timeout = 1600,\n  variant = \"secondary\",\n  size,\n  icons,\n  onCopy,\n  onCopyError,\n  onStatusChange,\n  className,\n  disabled,\n  type = \"button\",\n  ...props\n}: CopyButtonProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [status, setStatus] = useState<CopyButtonStatus>(\"idle\");\n  const timer = useRef<number | undefined>(undefined);\n  const text = { ...DEFAULT_LABELS, ...labels };\n  const resolvedSize = size ?? (showLabel ? \"sm\" : \"icon\");\n\n  useEffect(\n    () => () => {\n      if (timer.current) window.clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const update = useCallback(\n    (next: CopyButtonStatus) => {\n      setStatus(next);\n      onStatusChange?.(next);\n    },\n    [onStatusChange],\n  );\n\n  const handleClick = async () => {\n    if (timer.current) window.clearTimeout(timer.current);\n    try {\n      const resolved = typeof value === \"function\" ? await value() : value;\n      await copyToClipboard(resolved);\n      update(\"copied\");\n      onCopy?.(resolved);\n    } catch (error) {\n      update(\"error\");\n      onCopyError?.(error);\n    }\n    timer.current = window.setTimeout(() => update(\"idle\"), timeout);\n  };\n\n  const iconNode: Record<CopyButtonStatus, ReactNode> = {\n    idle: icons?.idle ?? <Copy />,\n    copied: icons?.copied ?? <Check className=\"text-(--color-success)\" />,\n    error: icons?.error ?? <X className=\"text-destructive\" />,\n  };\n\n  const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS;\n  const labelVariants = reduce ? FADE_VARIANTS : LABEL_VARIANTS;\n\n  return (\n    <>\n    <motion.button\n      type={type}\n      disabled={disabled}\n      onClick={handleClick}\n      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      aria-label={showLabel ? undefined : text[status]}\n      data-status={status}\n      className={cn(\n        \"relative inline-flex shrink-0 select-none items-center justify-center font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n        TOUCH_TARGET_CLASS,\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[resolvedSize],\n        className,\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className=\"relative grid size-3.5 place-items-center [&_svg]:size-3.5\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={status}\n            variants={iconVariants}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            className=\"col-start-1 row-start-1 grid place-items-center\"\n          >\n            {iconNode[status]}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      {showLabel ? (\n        <span className=\"relative grid overflow-hidden\">\n          {/* Every label sits invisibly in the same cell so the button keeps\n              the width of the longest one and never reflows mid-swap. */}\n          {(Object.keys(text) as CopyButtonStatus[]).map((key) => (\n            <span\n              key={key}\n              aria-hidden=\"true\"\n              className=\"invisible col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {text[key]}\n            </span>\n          ))}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={status}\n              variants={labelVariants}\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1 whitespace-nowrap text-left\"\n            >\n              {text[status]}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ) : null}\n    </motion.button>\n    {/* Outside the button so the announcement never joins its accessible name. */}\n    <span aria-live=\"polite\" className=\"sr-only\">\n      {status === \"copied\"\n        ? `${text.copied} to clipboard`\n        : status === \"error\"\n          ? `${text.error} to copy`\n          : \"\"}\n    </span>\n    </>\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":"components/motion/tabs.tsx","type":"registry:component","target":"@components/motion/tabs.tsx","content":"\"use client\";\n\nimport { motion, MotionConfig, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"pill\" | \"underline\" | \"segment\";\n\ntype Ctx = {\n  value: string;\n  setValue: (v: string) => void;\n  layoutId: string;\n  variant: Variant;\n};\n\nconst TabsCtx = createContext<Ctx | null>(null);\n\nfunction useTabs() {\n  const ctx = useContext(TabsCtx);\n  if (!ctx) throw new Error(\"Tabs.* must be used inside <Tabs>\");\n  return ctx;\n}\n\n// Weighty spring for the active-tab indicator: a touch of overshoot so it\n// settles with life instead of snapping.\nconst transition: Transition = {\n  type: \"spring\",\n  stiffness: 170,\n  damping: 24,\n  mass: 1.2,\n};\n\nexport function Tabs({\n  defaultValue,\n  value,\n  onValueChange,\n  variant = \"pill\",\n  children,\n  className,\n}: {\n  defaultValue?: string;\n  value?: string;\n  onValueChange?: (v: string) => void;\n  variant?: Variant;\n  children: ReactNode;\n  className?: string;\n}) {\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = useCallback(\n    (v: string) => {\n      if (!controlled) setInternal(v);\n      onValueChange?.(v);\n    },\n    [controlled, onValueChange],\n  );\n  const contextValue = useMemo(\n    () => ({ value: current, setValue, layoutId, variant }),\n    [current, layoutId, setValue, variant],\n  );\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : transition}>\n      <TabsCtx.Provider value={contextValue}>\n        {/* layoutRoot: the indicator's layoutId measures in page coordinates, so\n            inside fixed/scrolled containers it would replay scroll offsets as\n            movement. The pill only ever travels within the list, so scoping\n            projection to the Tabs wrapper is always correct. */}\n        <motion.div layoutRoot className={className}>\n          {children}\n        </motion.div>\n      </TabsCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nconst listClasses: Record<Variant, string> = {\n  pill: \"inline-flex items-center gap-1 rounded-full bg-card p-1\",\n  underline: \"inline-flex items-center gap-1 border-b border-border\",\n  segment: \"inline-flex items-center gap-0 rounded-lg bg-card p-0.5\",\n};\n\nexport function TabsList({ children, className }: { children: ReactNode; className?: string }) {\n  const { variant } = useTabs();\n  return (\n    <div role=\"tablist\" className={cn(listClasses[variant], className)}>\n      {children}\n    </div>\n  );\n}\n\nexport function TabsTrigger({\n  value,\n  children,\n  className,\n  indicatorClassName,\n}: {\n  value: string;\n  children: ReactNode;\n  className?: string;\n  indicatorClassName?: string;\n}) {\n  const { value: current, setValue, layoutId, variant } = useTabs();\n  const active = current === value;\n\n  if (variant === \"underline\") {\n    return (\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center\",\n          active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          className,\n        )}\n      >\n        {children}\n        {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout=\"position\"\n          className={cn(\n            \"absolute -bottom-px left-0 right-0 h-px bg-primary\",\n            indicatorClassName,\n          )}\n        />\n        ) : null}\n      </button>\n    );\n  }\n\n  const radius = variant === \"pill\" ? \"rounded-full\" : \"rounded-md\";\n\n  return (\n    <div className=\"relative\">\n      {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout=\"position\"\n          style={{ borderRadius: variant === \"pill\" ? 9999 : 8 }}\n          className={cn(\n            \"absolute inset-0 bg-primary\",\n            radius,\n            indicatorClassName,\n          )}\n        />\n      ) : null}\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none\",\n          TOUCH_TARGET_CLASS,\n          \"transition-colors\",\n          active\n            ? \"text-primary-foreground\"\n            : \"text-muted-foreground hover:text-foreground\",\n          radius,\n          className,\n        )}\n      >\n        {children}\n      </button>\n    </div>\n  );\n}\n\nexport function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {\n  const { value: current } = useTabs();\n  const reduce = useReducedMotion();\n  const active = current === value;\n  // Inactive panels stay mounted but hidden, so their content (e.g. source\n  // code) is present in the server-rendered HTML for crawlers and assistive\n  // tech, instead of being dropped from the DOM.\n  if (!active) {\n    return (\n      <div hidden className={className}>\n        {children}\n      </div>\n    );\n  }\n  return (\n    <motion.div\n      key={value}\n      initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n      className={cn(\"mt-4\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/text-shimmer.tsx","type":"registry:component","target":"@components/motion/text-shimmer.tsx","content":"import { cn } from \"@/lib/utils\";\nimport type { ElementType, ReactNode } from \"react\";\nimport {\n  TEXT_SHIMMER_CLASS_NAME,\n  TEXT_SHIMMER_KEYFRAMES,\n  textShimmerStyle,\n} from \"@/lib/text-shimmer\";\n\nexport interface TextShimmerProps {\n  children: ReactNode;\n  as?: ElementType;\n  duration?: number;\n  className?: string;\n}\n\nexport function TextShimmer({ children, as: Comp = \"span\", duration = 2.5, className }: TextShimmerProps) {\n  return (\n    <>\n      <style>\n        {TEXT_SHIMMER_KEYFRAMES}\n      </style>\n      <Comp\n        style={textShimmerStyle(duration)}\n        className={cn(\n          \"inline-block\",\n          TEXT_SHIMMER_CLASS_NAME,\n          className,\n        )}\n      >\n        {children}\n      </Comp>\n    </>\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":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/text-shimmer.ts","type":"registry:lib","target":"@lib/text-shimmer.ts","content":"import type { CSSProperties } from \"react\";\n\nexport const TEXT_SHIMMER_KEYFRAMES =\n  \"@keyframes karaui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}\";\n\nexport const TEXT_SHIMMER_CLASS_NAME =\n  \"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]\";\n\nexport function textShimmerStyle(duration: number): CSSProperties {\n  return {\n    animation: `karaui-text-shimmer ${duration}s linear infinite`,\n  };\n}\n"}]}