{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-composer","type":"registry:block","title":"Agent Composer","description":"AI agent chat composer with context chips, access chip, model picker with a segmented reasoning-effort slider, and a morphing send/stop button, plus an add menu and a voice dictation bar.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"cssVars":{"light":{"wb-hover":"rgb(0 0 0 / 0.05)","wb-hover-subtle":"rgb(0 0 0 / 0.04)","wb-hover-strong":"rgb(0 0 0 / 0.1)","wb-hover-stronger":"rgb(0 0 0 / 0.1)","wb-inset":"rgb(0 0 0 / 0.05)","wb-inset-strong":"rgb(0 0 0 / 0.06)","wb-inset-subtle":"rgb(0 0 0 / 0.03)","wb-inset-faint":"rgb(0 0 0 / 0.02)","wb-code-inline":"rgb(0 0 0 / 0.07)","wb-code-block":"rgb(0 0 0 / 0.04)","wb-border":"rgb(0 0 0 / 0.12)","wb-border-subtle":"rgb(0 0 0 / 0.1)","wb-border-strong":"rgb(0 0 0 / 0.17)","wb-border-emphasis":"rgb(0 0 0 / 0.22)","wb-divider":"rgb(0 0 0 / 0.07)","wb-hairline":"rgb(0 0 0 / 0.12)","wb-hairline-soft":"rgb(0 0 0 / 0.1)","wb-hairline-subtle":"rgb(0 0 0 / 0.08)","wb-control-hairline":"rgb(0 0 0 / 0.08)","wb-control-tick":"rgb(0 0 0 / 0.2)","wb-resize-handle":"rgb(0 0 0 / 0.2)","wb-surface":"rgb(255 255 255)","wb-surface-translucent":"oklch(94.5% 0.002 250 / 0.75)","wb-overlay-scrim":"rgb(0 0 0 / 0.06)","wb-surface-raised":"rgb(255 255 255 / 0.95)","wb-surface-composer":"rgb(255 255 255 / 0.9)","wb-card":"rgb(0 0 0 / 0.04)","wb-inverse":"oklch(20.5% 0 0)","wb-inverse-fg":"rgb(255 255 255)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(0 0 0 / 0.4)","wb-shimmer-bright":"rgb(0 0 0 / 0.85)","wb-accent":"#339cff","wb-success":"oklch(59.6% 0.145 163.225)","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"oklch(63.7% 0.237 25.331)","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#e25507","wb-warning-text":"oklch(64.6% 0.222 41.116)","wb-warning-hover":"oklch(70.5% 0.213 47.604)","wb-warning-surface":"#e25507"},"dark":{"wb-hover":"rgb(255 255 255 / 0.1)","wb-hover-subtle":"rgb(255 255 255 / 0.05)","wb-hover-strong":"rgb(255 255 255 / 0.1)","wb-hover-stronger":"rgb(255 255 255 / 0.15)","wb-inset":"rgb(255 255 255 / 0.05)","wb-inset-strong":"rgb(255 255 255 / 0.1)","wb-inset-subtle":"rgb(255 255 255 / 0.05)","wb-inset-faint":"rgb(255 255 255 / 0.03)","wb-code-inline":"rgb(255 255 255 / 0.1)","wb-code-block":"rgb(255 255 255 / 0.05)","wb-border":"rgb(255 255 255 / 0.1)","wb-border-subtle":"rgb(255 255 255 / 0.1)","wb-border-strong":"rgb(255 255 255 / 0.15)","wb-border-emphasis":"rgb(255 255 255 / 0.2)","wb-divider":"rgb(255 255 255 / 0.06)","wb-hairline":"rgb(255 255 255 / 0.157)","wb-hairline-soft":"rgb(255 255 255 / 0.15)","wb-hairline-subtle":"rgb(255 255 255 / 0.1)","wb-control-hairline":"rgb(255 255 255 / 0.08)","wb-control-tick":"rgb(255 255 255 / 0.3)","wb-resize-handle":"rgb(255 255 255 / 0.25)","wb-surface":"oklch(20.5% 0 0)","wb-surface-translucent":"oklch(20.5% 0 0 / 0.6)","wb-overlay-scrim":"rgb(0 0 0 / 0.24)","wb-surface-raised":"oklch(26.9% 0 0 / 0.95)","wb-surface-composer":"oklch(26.9% 0 0 / 0.9)","wb-card":"oklch(26.9% 0 0 / 0.5)","wb-inverse":"rgb(255 255 255)","wb-inverse-fg":"oklch(20.5% 0 0)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(255 255 255 / 0.4)","wb-shimmer-bright":"rgb(255 255 255 / 0.95)","wb-accent":"#339cff","wb-success":"#40c977","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"#fa423e","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#ff8549","wb-warning-text":"#ff8549","wb-warning-hover":"#e25507","wb-warning-surface":"#e25507"}},"files":[{"path":"components/motion/agent-composer/index.tsx","type":"registry:component","target":"@components/motion/agent-composer/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/agent-composer\n\nimport { ArrowUp, ChevronDown, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport { ComposerAutonomyDial } from \"./autonomy-dial\";\nexport { ComposerEffortSlider } from \"./effort-slider\";\n\nexport interface ComposerProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Composer shell — a translucent, rounded card that hosts a textarea and a\n * toolbar row. Renders `children` directly (auto-height textarea and toolbar\n * are stacked by the caller); the hairline shadow trio and glass surface use\n * the same `--composer-hairline` CSS-variable technique as\n * `WorkbenchSummaryCard`, so the right shade is picked per color scheme.\n * Sits at `z-10` so it stacks above a sibling `ComposerContextBar`.\n */\nexport function Composer({ className, children }: ComposerProps) {\n  return (\n    <div\n      style={{\n        boxShadow:\n          \"0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)\",\n      }}\n      className={cn(\n        \"relative z-10 flex flex-col rounded-[25px] bg-[var(--wb-surface-composer)] backdrop-blur-lg\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface ComposerContextBarProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Optional context bar meant to sit as a sibling right above `<Composer>` in\n * markup order. Its bottom padding is taller than its visible height and\n * pulled up with a negative margin, so `Composer` (rendered after it, at\n * `z-10`) sits on top and hides the lower half — the bar reads as tucked in\n * \"behind\" the composer shell.\n */\nexport function ComposerContextBar({ className, children }: ComposerContextBarProps) {\n  return (\n    <div\n      className={cn(\n        \"-mb-5 flex items-center gap-4 rounded-t-2xl bg-[var(--wb-inset)] px-2 pt-2 pb-7\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface ComposerChipProps {\n  icon?: ReactNode;\n  /** Swapped in for `icon` on hover/focus (opacity crossfade, no layout shift).\n   * Only meaningful on interactive chips — pair it with `onClick`. */\n  hoverIcon?: ReactNode;\n  /** Makes the chip a `<button>` with a hover pill surface. */\n  onClick?: () => void;\n  /** Native tooltip, e.g. \"Change project\". */\n  title?: string;\n  children?: ReactNode;\n  className?: string;\n}\n\nconst CHIP_BASE = \"flex h-7 items-center gap-1.5 rounded-full px-2 text-[13px] text-muted-foreground\";\n\n/**\n * A single label inside `ComposerContextBar` — an optional 16px icon plus\n * text. With `onClick` it becomes a button whose hover state paints a pill\n * surface and (when `hoverIcon` is set) crossfades the icon — e.g. a project\n * chip whose folder icon turns into a clear-mark.\n */\nexport function ComposerChip({\n  icon,\n  hoverIcon,\n  onClick,\n  title,\n  children,\n  className,\n}: ComposerChipProps) {\n  const iconSlot = icon ? (\n    hoverIcon ? (\n      <span className=\"relative flex h-4 w-4 shrink-0 items-center justify-center\">\n        <span className=\"absolute inset-0 flex items-center justify-center opacity-100 transition-opacity group-focus-visible:opacity-0 group-hover:opacity-0\">\n          {icon}\n        </span>\n        <span className=\"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-focus-visible:opacity-100 group-hover:opacity-100\">\n          {hoverIcon}\n        </span>\n      </span>\n    ) : (\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">{icon}</span>\n    )\n  ) : null;\n\n  if (onClick) {\n    return (\n      <button\n        type=\"button\"\n        onClick={onClick}\n        title={title}\n        className={cn(\n          CHIP_BASE,\n          \"group transition-colors hover:bg-[var(--wb-hover-strong)] hover:text-foreground\",\n          className,\n        )}\n      >\n        {iconSlot}\n        {children}\n      </button>\n    );\n  }\n\n  return (\n    <span title={title} className={cn(CHIP_BASE, className)}>\n      {iconSlot}\n      {children}\n    </span>\n  );\n}\n\nexport interface ComposerTextareaProps {\n  value: string;\n  onChange: (next: string) => void;\n  placeholder?: string;\n  /** Fires when Enter is pressed without Shift — the caller owns what \"submit\" means. */\n  onSubmit?: () => void;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/**\n * Auto-growing message field. A real `<textarea rows={1}>` — height tracks\n * content via `scrollHeight`, up to the scrollable container's\n * `max-h-[25dvh]`. The measure runs in a layout effect keyed on `value` (not\n * an input handler), so programmatic updates — e.g. the caller clearing the\n * draft after a send — collapse the field just like keystrokes do. Enter\n * submits (and is prevented from inserting a newline); Shift+Enter inserts a\n * newline as usual.\n */\nexport function ComposerTextarea({\n  value,\n  onChange,\n  placeholder,\n  onSubmit,\n  \"aria-label\": ariaLabel,\n  className,\n}: ComposerTextareaProps) {\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `value` is the trigger — the height depends on the rendered content, which this effect reads from the DOM rather than from the prop.\n  useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${el.scrollHeight}px`;\n  }, [value]);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (event.key === \"Enter\" && !event.shiftKey) {\n        event.preventDefault();\n        onSubmit?.();\n      }\n    },\n    [onSubmit],\n  );\n\n  return (\n    <div className=\"max-h-[25dvh] overflow-y-auto px-4 pt-3.5 pb-1\">\n      <textarea\n        ref={textareaRef}\n        rows={1}\n        value={value}\n        onChange={(event) => onChange(event.target.value)}\n        onKeyDown={onKeyDown}\n        placeholder={placeholder}\n        aria-label={ariaLabel}\n        className={cn(\n          \"min-h-[44px] w-full resize-none border-none bg-transparent text-sm leading-5 outline-none\",\n          \"placeholder:text-muted-foreground\",\n          className,\n        )}\n      />\n    </div>\n  );\n}\n\nexport interface ComposerToolbarProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Bottom action row. Just a flex container — order children left to right\n * and drop in a `<div className=\"ml-auto\" />` spacer to split left/right\n * clusters (see the preview for the exact arrangement).\n */\nexport function ComposerToolbar({ className, children }: ComposerToolbarProps) {\n  return <div className={cn(\"flex items-center gap-1 px-2 pb-2\", className)}>{children}</div>;\n}\n\nexport interface ComposerIconButtonProps {\n  \"aria-label\": string;\n  onClick?: () => void;\n  children?: ReactNode;\n  className?: string;\n  disabled?: boolean;\n}\n\n/** Circular 28px icon button for the toolbar (add attachment, dictate, ...). */\nexport function ComposerIconButton({\n  \"aria-label\": ariaLabel,\n  onClick,\n  children,\n  className,\n  disabled,\n}: ComposerIconButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={ariaLabel}\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors\",\n        \"hover:bg-[var(--wb-hover)] hover:text-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport interface ComposerAccessChipProps {\n  icon?: ReactNode;\n  /** \"warning\" (default) reads as an orange access-level alert; \"default\" is muted. */\n  tone?: \"warning\" | \"default\";\n  onClick?: () => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/** Pill button surfacing the current permission level (e.g. \"Full access\"). */\nexport function ComposerAccessChip({\n  icon,\n  tone = \"warning\",\n  onClick,\n  children,\n  className,\n}: ComposerAccessChipProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] transition-colors\",\n        tone === \"warning\"\n          ? \"text-[var(--wb-warning-text)] hover:bg-[var(--wb-warning-hover)]/10\"\n          : \"text-muted-foreground hover:bg-[var(--wb-hover)]\",\n        className,\n      )}\n    >\n      {icon ? <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">{icon}</span> : null}\n      {children}\n    </button>\n  );\n}\n\n/**\n * Shared open state + dismiss behavior for the popover triggers below.\n * Controlled (`openProp`/`onOpenChange`) or uncontrolled; while open, Escape\n * or a pointerdown outside `rootRef` closes.\n */\nfunction usePopover(openProp: boolean | undefined, onOpenChange?: (open: boolean) => void) {\n  const [openState, setOpenState] = useState(false);\n  const open = openProp ?? openState;\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      setOpenState(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n    };\n    const onPointerDown = (event: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown);\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n    };\n  }, [open, setOpen]);\n\n  return { open, setOpen, rootRef };\n}\n\ninterface PopoverPanelProps {\n  open: boolean;\n  /** Horizontal anchor against the trigger: \"start\" = left edges flush, \"end\" = right edges flush. */\n  align: \"start\" | \"end\";\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * The floating panel shell shared by `ComposerModelPicker` and\n * `ComposerMenuButton` — glass surface, hairline shadow trio, and the\n * scale/y/opacity + `SPRING_PANEL` entrance from `WorkbenchSummaryCard`,\n * reduced to an opacity-only fade under `useReducedMotion()`. Anchored above\n * the trigger; `align` picks the flush edge and the transform origin.\n */\nfunction PopoverPanel({ open, align, className, children }: PopoverPanelProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <motion.div\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n          animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n          transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}\n          style={{\n            transformOrigin: align === \"end\" ? \"bottom right\" : \"bottom left\",\n            boxShadow:\n              \"0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)\",\n          }}\n          className={cn(\n            \"absolute bottom-[calc(100%+8px)] z-20 min-w-[224px] rounded-2xl bg-[var(--wb-surface-raised)] p-1 backdrop-blur-xl\",\n            align === \"end\" ? \"right-0\" : \"left-0\",\n            className,\n          )}\n        >\n          {children}\n        </motion.div>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nexport interface ComposerModelPickerProps {\n  /** Trigger content, e.g. `\"5.6 · High\"` — a chevron is appended automatically. */\n  label: ReactNode;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * Trigger plus a popover that springs open above and right-aligned to it —\n * for model/effort pickers. Works controlled (`open`/`onOpenChange`) or\n * uncontrolled; dismiss and entrance behavior come from `usePopover` /\n * `PopoverPanel`.\n */\nexport function ComposerModelPicker({\n  label,\n  open: openProp,\n  onOpenChange,\n  children,\n  className,\n}: ComposerModelPickerProps) {\n  const { open, setOpen, rootRef } = usePopover(openProp, onOpenChange);\n\n  return (\n    <div ref={rootRef} className=\"relative\">\n      <button\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        onClick={() => setOpen(!open)}\n        className={cn(\n          \"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] text-muted-foreground\",\n          \"hover:bg-[var(--wb-hover)]\",\n          className,\n        )}\n      >\n        {label}\n        <ChevronDown className=\"h-3.5 w-3.5\" />\n      </button>\n      <PopoverPanel open={open} align=\"end\">\n        {children}\n      </PopoverPanel>\n    </div>\n  );\n}\n\nexport interface ComposerMenuButtonProps {\n  /** 16px icon; alone it renders the circular icon-button look. */\n  icon?: ReactNode;\n  /** Optional text label; with it the trigger becomes a pill like the model-picker trigger (no chevron). */\n  label?: ReactNode;\n  \"aria-label\": string;\n  /** Panel anchor edge — defaults to \"start\" (left-aligned above the trigger). */\n  align?: \"start\" | \"end\";\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * Trigger plus a popover menu (compose `ComposerMenuSection` /\n * `ComposerMenuItem` as children) — e.g. the \"+\" add-attachments menu.\n * Controlled or uncontrolled; same panel shell and dismiss behavior as\n * `ComposerModelPicker`.\n */\nexport function ComposerMenuButton({\n  icon,\n  label,\n  \"aria-label\": ariaLabel,\n  align = \"start\",\n  open: openProp,\n  onOpenChange,\n  children,\n  className,\n}: ComposerMenuButtonProps) {\n  const { open, setOpen, rootRef } = usePopover(openProp, onOpenChange);\n\n  return (\n    <div ref={rootRef} className=\"relative\">\n      <button\n        type=\"button\"\n        aria-label={ariaLabel}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        onClick={() => setOpen(!open)}\n        className={cn(\n          label\n            ? \"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] text-muted-foreground hover:bg-[var(--wb-hover)]\"\n            : \"flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground\",\n          className,\n        )}\n      >\n        {icon}\n        {label}\n      </button>\n      <PopoverPanel open={open} align={align} className=\"min-w-[260px] max-w-[320px]\">\n        {children}\n      </PopoverPanel>\n    </div>\n  );\n}\n\nexport interface ComposerMenuSectionProps {\n  title?: ReactNode;\n  children?: ReactNode;\n  className?: string;\n}\n\n/** A titled group of rows inside a `ComposerMenuButton` panel. */\nexport function ComposerMenuSection({ title, children, className }: ComposerMenuSectionProps) {\n  return (\n    <div className={cn(\"flex flex-col\", className)}>\n      {title ? <div className=\"px-2 pt-1.5 pb-1 text-muted-foreground text-xs\">{title}</div> : null}\n      {children}\n    </div>\n  );\n}\n\nexport interface ComposerMenuItemProps {\n  icon?: ReactNode;\n  /** Muted one-liner rendered inline after the name, truncated when tight. */\n  description?: ReactNode;\n  onSelect?: () => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/** One selectable row in a `ComposerMenuButton` panel. */\nexport function ComposerMenuItem({\n  icon,\n  description,\n  onSelect,\n  children,\n  className,\n}: ComposerMenuItemProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onSelect}\n      className={cn(\n        \"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors\",\n        \"hover:bg-[var(--wb-hover)]\",\n        className,\n      )}\n    >\n      {icon ? (\n        <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"whitespace-nowrap\">{children}</span>\n      {description ? <span className=\"min-w-0 truncate text-muted-foreground\">{description}</span> : null}\n    </button>\n  );\n}\n\nexport interface ComposerSendButtonProps {\n  running?: boolean;\n  disabled?: boolean;\n  onClick?: () => void;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/**\n * Morphing send/stop control. `running` swaps the arrow glyph for a solid\n * square via `AnimatePresence mode=\"wait\"` — a scale+opacity springy pop\n * (`SPRING_PANEL`), reduced to a plain opacity cut under\n * `useReducedMotion()`.\n */\nexport function ComposerSendButton({\n  running = false,\n  disabled,\n  onClick,\n  \"aria-label\": ariaLabel,\n  className,\n}: ComposerSendButtonProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={ariaLabel ?? (running ? \"Stop\" : \"Send\")}\n      className={cn(\n        \"flex h-7 w-7 items-center justify-center rounded-full bg-foreground text-background\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        className,\n      )}\n    >\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {running ? (\n          <motion.span\n            key=\"stop\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}\n            className=\"block h-2.5 w-2.5 rounded-[2px] bg-current\"\n          />\n        ) : (\n          <motion.span\n            key=\"send\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}\n            className=\"flex items-center justify-center\"\n          >\n            <ArrowUp className=\"h-4 w-4\" />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n\n/** 3px dash / 4px gap horizontal dash pattern, colored by `currentColor`. */\nconst DICTATION_DASHES = \"repeating-linear-gradient(90deg, currentColor 0 3px, transparent 3px 7px)\";\n\nexport interface ComposerDictationProps {\n  /** Elapsed recording time, owned by the caller; formatted as m:ss. */\n  seconds: number;\n  onStop?: () => void;\n  className?: string;\n  /** Label for the stop button — defaults to \"Stop dictation\". */\n  \"aria-label\"?: string;\n}\n\n/**\n * Voice-dictation state for the toolbar's middle stretch: a dashed sound\n * track whose brighter right-end segment slowly flows leftward while\n * listening, an m:ss timer, and a white stop button (white in both themes).\n * The flow loops over exactly one dash period so it reads as continuous; a\n * static track is shown under `useReducedMotion()`.\n */\nexport function ComposerDictation({\n  seconds,\n  onStop,\n  className,\n  \"aria-label\": ariaLabel,\n}: ComposerDictationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const minutes = Math.floor(seconds / 60);\n  const remainder = `${Math.floor(seconds % 60)}`.padStart(2, \"0\");\n\n  return (\n    <div className={cn(\"flex items-center gap-3\", className)}>\n      <div\n        aria-hidden\n        className=\"relative h-px flex-1 overflow-hidden text-muted-foreground/50\"\n        style={{ backgroundImage: DICTATION_DASHES }}\n      >\n        <motion.div\n          className=\"absolute inset-y-0 right-0 w-[72px] text-foreground/80\"\n          style={{ backgroundImage: DICTATION_DASHES }}\n          animate={reduce ? undefined : { backgroundPositionX: [\"0px\", \"-7px\"] }}\n          transition={reduce ? undefined : { duration: 0.6, repeat: Infinity, ease: \"linear\" }}\n        />\n      </div>\n      <span className=\"text-[13px] text-muted-foreground tabular-nums\">\n        {minutes}:{remainder}\n      </span>\n      <button\n        type=\"button\"\n        aria-label={ariaLabel ?? \"Stop dictation\"}\n        onClick={onStop}\n        className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--wb-accent-fg)] text-[var(--wb-solid-control-fg)] shadow-[0_0_0_0.5px_rgba(0,0,0,0.08),0_1px_4px_rgba(0,0,0,0.2)]\"\n      >\n        <span className=\"block h-2.5 w-2.5 rounded-[2px] bg-current\" />\n      </button>\n    </div>\n  );\n}\n\nexport interface ComposerAttachmentsProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Row of attachment chips shown above `ComposerTextarea` — a plain\n * flex-wrap container. Wrap the mapped `ComposerAttachmentChip` list in the\n * caller's own `AnimatePresence` to animate removal (see the preview); this\n * component only supplies the layout.\n */\nexport function ComposerAttachments({ className, children }: ComposerAttachmentsProps) {\n  return <div className={cn(\"flex flex-wrap gap-1.5 px-4 pt-3\", className)}>{children}</div>;\n}\n\nexport interface ComposerAttachmentChipProps {\n  /** 14px icon, e.g. `<FileText className=\"h-3.5 w-3.5\" />`. */\n  icon?: ReactNode;\n  name: ReactNode;\n  /** Muted trailing detail, e.g. a file size. */\n  meta?: ReactNode;\n  onRemove?: () => void;\n  className?: string;\n}\n\n/**\n * One attached file/image chip inside `ComposerAttachments`. Carries\n * `layout` so sibling chips reflow smoothly when one is added or removed;\n * the removal transition itself is the caller's responsibility (wrap the\n * list in `AnimatePresence`).\n */\nexport function ComposerAttachmentChip({\n  icon,\n  name,\n  meta,\n  onRemove,\n  className,\n}: ComposerAttachmentChipProps) {\n  return (\n    <motion.div\n      layout\n      className={cn(\n        \"group/att flex items-center gap-1.5 rounded-lg border border-[var(--wb-border)] bg-[var(--wb-inset-subtle)] py-1 pr-1 pl-2 text-[13px]\",\n        className,\n      )}\n    >\n      {icon ? (\n        <span className=\"flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"max-w-40 truncate text-foreground\">{name}</span>\n      {meta ? <span className=\"text-muted-foreground text-xs\">{meta}</span> : null}\n      {onRemove ? (\n        <button\n          type=\"button\"\n          aria-label={typeof name === \"string\" ? `Remove ${name}` : \"Remove attachment\"}\n          onClick={onRemove}\n          className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-[var(--wb-hover-strong)]\"\n        >\n          <X className=\"h-3 w-3\" />\n        </button>\n      ) : null}\n    </motion.div>\n  );\n}\n\nexport interface ComposerContextGaugeProps {\n  used: number;\n  limit: number;\n  /** Defaults to a K-abbreviated \"32K / 200K\" readout. */\n  formatLabel?: (used: number, limit: number) => ReactNode;\n  className?: string;\n}\n\nfunction formatContextK(n: number) {\n  return n >= 1000 ? `${Math.round(n / 1000)}K` : `${n}`;\n}\n\nfunction defaultContextLabel(used: number, limit: number) {\n  return `${formatContextK(used)} / ${formatContextK(limit)}`;\n}\n\n/**\n * Token-budget gauge for the toolbar or a `ComposerContextBar` — a thin\n * track filled to `used / limit`, colored blue under 80%, orange from\n * 80–95%, red past that. The fill width animates with `SPRING_PANEL`,\n * reduced to an instant cut under `useReducedMotion()`.\n */\nexport function ComposerContextGauge({\n  used,\n  limit,\n  formatLabel = defaultContextLabel,\n  className,\n}: ComposerContextGaugeProps) {\n  const reduce = useReducedMotion() ?? false;\n  const ratio = limit > 0 ? Math.min(Math.max(used / limit, 0), 1) : 0;\n  const tone = ratio < 0.8 ? \"safe\" : ratio < 0.95 ? \"warn\" : \"crit\";\n\n  return (\n    <div className={cn(\"flex items-center gap-1.5\", className)}>\n      <div className=\"h-1 w-16 overflow-hidden rounded-full bg-[var(--wb-border)]\">\n        <motion.div\n          initial={false}\n          animate={{ width: `${ratio * 100}%` }}\n          transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}\n          className={cn(\n            \"h-full rounded-full\",\n            tone === \"safe\" && \"bg-[var(--wb-accent)]\",\n            tone === \"warn\" && \"bg-[var(--wb-warning)]\",\n            tone === \"crit\" && \"bg-[var(--wb-danger)]\",\n          )}\n        />\n      </div>\n      <span className=\"text-muted-foreground text-xs tabular-nums\">\n        {formatLabel(used, limit)}\n      </span>\n    </div>\n  );\n}\n"},{"path":"components/motion/agent-composer/effort-slider.tsx","type":"registry:component","target":"@components/motion/agent-composer/effort-slider.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/agent-composer\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_LABELS = [\"Minimal\", \"Low\", \"Standard\", \"High\", \"Max\"];\n/** Tick centers are inset this many px from either end of the track so the\n * end dots clear the rounded caps. */\nconst TRACK_INSET = 12;\n/** How long the one-shot thumb bounce plays after landing on the top tier. */\nconst MAX_BOUNCE_MS = 400;\n\nexport interface ComposerEffortSliderProps {\n  value: number;\n  onChange: (next: number) => void;\n  /** Step labels — length sets the number of segments (defaults to 5). */\n  labels?: string[];\n  \"aria-label\"?: string;\n  className?: string;\n  disabled?: boolean;\n}\n\n/**\n * Segmented reasoning-effort slider. Snaps to one of `labels.length` evenly\n * spaced steps via drag (pointer capture) or the keyboard (ArrowLeft/Right,\n * Home/End). Reaching the last step bounces the thumb once and sends out a\n * pair of accent-token ripple rings — both skipped under\n * `useReducedMotion()`, which also drops the spring glide in favor of an\n * instant snap.\n */\nexport function ComposerEffortSlider({\n  value,\n  onChange,\n  labels = DEFAULT_LABELS,\n  \"aria-label\": ariaLabel,\n  className,\n  disabled = false,\n}: ComposerEffortSliderProps) {\n  const reduce = useReducedMotion() ?? false;\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [trackWidth, setTrackWidth] = useState(0);\n  // False until the frame carrying the first real measurement has painted —\n  // fill/thumb transitions run at duration 0 while false, so mounting inside\n  // a popover lands them in place instead of sweeping in from the left edge.\n  const [ready, setReady] = useState(false);\n  const [dragging, setDragging] = useState(false);\n  const [justMaxed, setJustMaxed] = useState(false);\n\n  const maxIndex = Math.max(0, labels.length - 1);\n  const clampedValue = Math.min(maxIndex, Math.max(0, value));\n  const isMax = maxIndex > 0 && clampedValue === maxIndex;\n\n  // Fires the one-shot bounce only on the transition into the top tier, not\n  // on every render while already there.\n  const wasMaxRef = useRef(isMax);\n  useEffect(() => {\n    const wasMax = wasMaxRef.current;\n    wasMaxRef.current = isMax;\n    if (isMax && !wasMax && !reduce) {\n      setJustMaxed(true);\n      const timer = setTimeout(() => setJustMaxed(false), MAX_BOUNCE_MS);\n      return () => clearTimeout(timer);\n    }\n  }, [isMax, reduce]);\n\n  // Synchronous first measure so the initial thumb position is correct\n  // before paint, then keep it correct if the track is ever resized (e.g. a\n  // wider className override). `offsetWidth` (layout width) rather than a\n  // bounding rect: a host popover's scale entrance would otherwise skew the\n  // measure and leave the geometry permanently off by the entrance scale.\n  // `ready` flips only after a double rAF — i.e. after the browser has\n  // painted the correctly-placed first frame — so the springs can't animate\n  // the 0-width → measured-width jump.\n  useLayoutEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    setTrackWidth(el.offsetWidth);\n    let raf2 = 0;\n    const raf1 = requestAnimationFrame(() => {\n      raf2 = requestAnimationFrame(() => setReady(true));\n    });\n    return () => {\n      cancelAnimationFrame(raf1);\n      cancelAnimationFrame(raf2);\n    };\n  }, []);\n\n  useEffect(() => {\n    const el = trackRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setTrackWidth(el.offsetWidth));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const centerFor = useCallback(\n    (index: number) => {\n      const usable = Math.max(0, trackWidth - TRACK_INSET * 2);\n      const step = maxIndex > 0 ? usable / maxIndex : 0;\n      return TRACK_INSET + step * index;\n    },\n    [trackWidth, maxIndex],\n  );\n\n  const indexFromClientX = useCallback(\n    (clientX: number) => {\n      const el = trackRef.current;\n      if (!el || maxIndex === 0) return 0;\n      const rect = el.getBoundingClientRect();\n      const usable = Math.max(1, rect.width - TRACK_INSET * 2);\n      const ratio = (clientX - rect.left - TRACK_INSET) / usable;\n      return Math.round(Math.min(1, Math.max(0, ratio)) * maxIndex);\n    },\n    [maxIndex],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setDragging(true);\n      onChange(indexFromClientX(event.clientX));\n    },\n    [disabled, indexFromClientX, onChange],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!dragging || disabled) return;\n      onChange(indexFromClientX(event.clientX));\n    },\n    [dragging, disabled, indexFromClientX, onChange],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.releasePointerCapture?.(event.pointerId);\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowLeft: clampedValue - 1,\n        ArrowRight: clampedValue + 1,\n        Home: 0,\n        End: maxIndex,\n      };\n      const next = map[event.key];\n      if (next !== undefined) {\n        event.preventDefault();\n        onChange(Math.min(maxIndex, Math.max(0, next)));\n      }\n    },\n    [clampedValue, disabled, maxIndex, onChange],\n  );\n\n  const thumbCenter = centerFor(clampedValue);\n  const thumbScale = reduce ? 1 : justMaxed ? [1, 1.15, 1] : dragging ? 1.08 : 1;\n  // Instant placement until the first measured frame has painted, and always\n  // under reduced motion; spring glide otherwise.\n  const glideTransition = !ready || reduce ? { duration: 0 } : SPRING_PANEL;\n\n  return (\n    <div\n      ref={trackRef}\n      role=\"slider\"\n      aria-valuemin={0}\n      aria-valuemax={maxIndex}\n      aria-valuenow={clampedValue}\n      aria-valuetext={labels[clampedValue]}\n      aria-label={ariaLabel}\n      aria-disabled={disabled || undefined}\n      tabIndex={disabled ? -1 : 0}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"relative h-6 w-[200px] touch-none select-none overflow-visible rounded-full bg-[var(--wb-inset-strong)] outline-none\",\n        \"shadow-[inset_0_0_0_0.5px_var(--wb-control-hairline)]\",\n        \"focus-visible:ring-2 focus-visible:ring-[var(--wb-accent)]/50\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n    >\n      {/* fill — left edge to the thumb center */}\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-y-0 left-0 rounded-full\"\n        style={{ backgroundColor: \"var(--wb-accent)\" }}\n        animate={{ width: thumbCenter }}\n        transition={glideTransition}\n      />\n\n      {/* tick dots */}\n      {labels.map((label, index) => (\n        <span\n          key={label}\n          aria-hidden\n          className={cn(\n            \"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 h-1 w-1 rounded-full\",\n            index <= clampedValue\n              ? \"bg-[var(--wb-accent-fg)]/50\"\n              : \"bg-[var(--wb-control-tick)]\",\n          )}\n          style={{ left: centerFor(index) }}\n        />\n      ))}\n\n      {/* thumb */}\n      <motion.div\n        aria-hidden\n        className=\"absolute top-0 h-7 w-7 rounded-full bg-[var(--wb-accent-fg)]\"\n        style={{\n          y: \"-2px\",\n          boxShadow:\n            \"0 0 0 0.5px var(--wb-control-hairline), 0 1px 4px color-mix(in srgb, var(--wb-inverse) 24%, transparent)\",\n        }}\n        animate={{ left: thumbCenter, x: \"-50%\", scale: thumbScale }}\n        transition={{\n          left: glideTransition,\n          x: { duration: 0 },\n          scale: justMaxed ? { duration: MAX_BOUNCE_MS / 1000, ease: EASE_OUT } : SPRING_PANEL,\n        }}\n      >\n        {isMax && !reduce ? (\n          <>\n            <RippleRing delay={0} />\n            <RippleRing delay={0.9} />\n          </>\n        ) : null}\n      </motion.div>\n    </div>\n  );\n}\n\n/** One expanding ring in the \"reached max effort\" ripple pair. */\nfunction RippleRing({ delay }: { delay: number }) {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"pointer-events-none absolute inset-0 rounded-full border-2\"\n      style={{ borderColor: \"color-mix(in srgb, var(--wb-accent) 60%, transparent)\" }}\n      initial={{ scale: 1, opacity: 0.6 }}\n      animate={{ scale: 2.4, opacity: 0 }}\n      transition={{ duration: 1.8, repeat: Infinity, ease: \"easeOut\", delay }}\n    />\n  );\n}\n"},{"path":"components/motion/agent-composer/autonomy-dial.tsx","type":"registry:component","target":"@components/motion/agent-composer/autonomy-dial.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/agent-composer\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_LABELS = [\"Suggest only\", \"Ask first\", \"Scoped auto\", \"Full auto\"];\n/** Tick centers are inset this many px from either end of the track so the\n * end dots clear the rounded caps. */\nconst TRACK_INSET = 12;\n\nexport interface ComposerAutonomyDialProps {\n  value: number;\n  onChange: (next: number) => void;\n  /** Step labels — length sets the number of tiers (defaults to 4). */\n  labels?: string[];\n  \"aria-label\"?: string;\n  className?: string;\n  disabled?: boolean;\n}\n\n/** Blue for the low tiers, amber for the one below max, red at max — the\n * fill climbs like risk does as the dial approaches unsupervised action. */\nfunction fillColorFor(index: number, maxIndex: number) {\n  if (maxIndex <= 0 || index >= maxIndex) return \"var(--wb-danger-strong)\";\n  if (index === maxIndex - 1) return \"var(--wb-warning)\";\n  return \"var(--wb-accent)\";\n}\n\n/**\n * Segmented autonomy dial — how free the agent is to act without checking\n * in, from suggestion-only up to fully unsupervised. Built on the same\n * measured-track / pointer-capture / keyboard skeleton as\n * `ComposerEffortSlider` (a \"risk-tiered autonomy\" scale: the further the\n * dial travels, the less the agent asks first), but themed for risk instead\n * of compute: the fill color climbs from blue through amber to red as the\n * tier rises, and the top tier breathes a red ring around the thumb instead\n * of rippling — that ripple is the effort slider's own signature, kept\n * unique to it. Snaps to one of `labels.length` evenly spaced steps via drag\n * or the keyboard (ArrowLeft/Right, Home/End); the color glide and thumb\n * glide both collapse to instant cuts under `useReducedMotion()`, while the\n * warning ring stays visible but stops animating rather than disappearing.\n */\nexport function ComposerAutonomyDial({\n  value,\n  onChange,\n  labels = DEFAULT_LABELS,\n  \"aria-label\": ariaLabel,\n  className,\n  disabled = false,\n}: ComposerAutonomyDialProps) {\n  const reduce = useReducedMotion() ?? false;\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [trackWidth, setTrackWidth] = useState(0);\n  // False until the frame carrying the first real measurement has painted —\n  // fill/thumb transitions run at duration 0 while false, so mounting inside\n  // a popover lands them in place instead of sweeping in from the left edge.\n  const [ready, setReady] = useState(false);\n  const [dragging, setDragging] = useState(false);\n\n  const maxIndex = Math.max(0, labels.length - 1);\n  const clampedValue = Math.min(maxIndex, Math.max(0, value));\n  const isMax = maxIndex > 0 && clampedValue === maxIndex;\n\n  // Synchronous first measure so the initial thumb position is correct\n  // before paint, then keep it correct if the track is ever resized (e.g. a\n  // wider className override). `offsetWidth` (layout width) rather than a\n  // bounding rect: a host popover's scale entrance would otherwise skew the\n  // measure and leave the geometry permanently off by the entrance scale.\n  // `ready` flips only after a double rAF — i.e. after the browser has\n  // painted the correctly-placed first frame — so the springs can't animate\n  // the 0-width → measured-width jump.\n  useLayoutEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    setTrackWidth(el.offsetWidth);\n    let raf2 = 0;\n    const raf1 = requestAnimationFrame(() => {\n      raf2 = requestAnimationFrame(() => setReady(true));\n    });\n    return () => {\n      cancelAnimationFrame(raf1);\n      cancelAnimationFrame(raf2);\n    };\n  }, []);\n\n  useEffect(() => {\n    const el = trackRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setTrackWidth(el.offsetWidth));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const centerFor = useCallback(\n    (index: number) => {\n      const usable = Math.max(0, trackWidth - TRACK_INSET * 2);\n      const step = maxIndex > 0 ? usable / maxIndex : 0;\n      return TRACK_INSET + step * index;\n    },\n    [trackWidth, maxIndex],\n  );\n\n  const indexFromClientX = useCallback(\n    (clientX: number) => {\n      const el = trackRef.current;\n      if (!el || maxIndex === 0) return 0;\n      const rect = el.getBoundingClientRect();\n      const usable = Math.max(1, rect.width - TRACK_INSET * 2);\n      const ratio = (clientX - rect.left - TRACK_INSET) / usable;\n      return Math.round(Math.min(1, Math.max(0, ratio)) * maxIndex);\n    },\n    [maxIndex],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setDragging(true);\n      onChange(indexFromClientX(event.clientX));\n    },\n    [disabled, indexFromClientX, onChange],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!dragging || disabled) return;\n      onChange(indexFromClientX(event.clientX));\n    },\n    [dragging, disabled, indexFromClientX, onChange],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.releasePointerCapture?.(event.pointerId);\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowLeft: clampedValue - 1,\n        ArrowRight: clampedValue + 1,\n        Home: 0,\n        End: maxIndex,\n      };\n      const next = map[event.key];\n      if (next !== undefined) {\n        event.preventDefault();\n        onChange(Math.min(maxIndex, Math.max(0, next)));\n      }\n    },\n    [clampedValue, disabled, maxIndex, onChange],\n  );\n\n  const thumbCenter = centerFor(clampedValue);\n  const thumbScale = reduce ? 1 : dragging ? 1.08 : 1;\n  const fillColor = fillColorFor(clampedValue, maxIndex);\n  // Instant placement until the first measured frame has painted, and always\n  // under reduced motion; spring glide otherwise.\n  const glideTransition = !ready || reduce ? { duration: 0 } : SPRING_PANEL;\n  const colorTransition = !ready || reduce ? { duration: 0 } : { duration: 0.25, ease: EASE_OUT };\n\n  return (\n    <div className={cn(\"inline-flex flex-col items-center\", className)}>\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        aria-valuemin={0}\n        aria-valuemax={maxIndex}\n        aria-valuenow={clampedValue}\n        aria-valuetext={labels[clampedValue]}\n        aria-label={ariaLabel}\n        aria-disabled={disabled || undefined}\n        tabIndex={disabled ? -1 : 0}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"relative h-6 w-[200px] touch-none select-none overflow-visible rounded-full bg-[var(--wb-inset-strong)] outline-none\",\n          \"shadow-[inset_0_0_0_0.5px_var(--wb-control-hairline)]\",\n          \"focus-visible:ring-2 focus-visible:ring-[var(--wb-accent)]/50\",\n          disabled && \"pointer-events-none opacity-50\",\n        )}\n      >\n        {/* fill — left edge to the thumb center */}\n        <motion.div\n          aria-hidden\n          className=\"absolute inset-y-0 left-0 rounded-full\"\n          initial={false}\n          animate={{ width: thumbCenter, backgroundColor: fillColor }}\n          transition={{ width: glideTransition, backgroundColor: colorTransition }}\n        />\n\n        {/* tick dots */}\n        {labels.map((label, index) => (\n          <span\n            key={label}\n            aria-hidden\n            className={cn(\n              \"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 h-1 w-1 rounded-full\",\n              index <= clampedValue\n                ? \"bg-[var(--wb-accent-fg)]/50\"\n                : \"bg-[var(--wb-control-tick)]\",\n            )}\n            style={{ left: centerFor(index) }}\n          />\n        ))}\n\n        {/* thumb */}\n        <motion.div\n          aria-hidden\n          className=\"absolute top-0 h-7 w-7 rounded-full bg-[var(--wb-accent-fg)]\"\n          style={{\n            y: \"-2px\",\n            boxShadow: \"0 0 0 0.5px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.24)\",\n          }}\n          animate={{ left: thumbCenter, x: \"-50%\", scale: thumbScale }}\n          transition={{\n            left: glideTransition,\n            x: { duration: 0 },\n            scale: SPRING_PANEL,\n          }}\n        >\n          {isMax ? (\n            reduce ? (\n              <span\n                aria-hidden\n                className=\"pointer-events-none absolute inset-0 rounded-full\"\n                style={{\n                  boxShadow:\n                    \"0 0 0 3px color-mix(in srgb, var(--wb-danger-strong) 50%, transparent)\",\n                }}\n              />\n            ) : (\n              <motion.span\n                aria-hidden\n                className=\"pointer-events-none absolute inset-0 rounded-full\"\n                style={{\n                  boxShadow:\n                    \"0 0 0 3px color-mix(in srgb, var(--wb-danger-strong) 25%, transparent)\",\n                }}\n                animate={{ opacity: [0.4, 0.8, 0.4] }}\n                transition={{ duration: 1.6, repeat: Infinity, ease: \"easeInOut\" }}\n              />\n            )\n          ) : null}\n        </motion.div>\n      </div>\n\n      {/* current tier label */}\n      <div className=\"mt-1.5 text-xs text-muted-foreground\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.span\n            key={clampedValue}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n            className={cn(\"inline-block\", isMax && \"text-[var(--wb-danger-strong)]\")}\n          >\n            {labels[clampedValue]}\n          </motion.span>\n        </AnimatePresence>\n      </div>\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"},{"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"}]}