{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"roadmap","type":"registry:block","title":"Roadmap","description":"Public product roadmap with Planned, In progress and Shipped columns or a quarter timeline, where cards fly between the two views through shared layout, re-sort by votes as you upvote with a direction-aware rolling count, carry tag badges, and can be filtered by status.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/roadmap.tsx","type":"registry:component","target":"@components/motion/roadmap.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/roadmap\n\nimport { ChevronUp, Columns3, ListTree } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { useCallback, useId, useMemo, useState } from \"react\";\nimport { Badge, type BadgeVariant } from \"@/components/motion/badge\";\nimport { EmptyState } from \"@/components/motion/empty-state\";\nimport { SegmentedControl } from \"@/components/motion/segmented-control\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/motion/toggle-group\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RoadmapStatus = \"planned\" | \"in-progress\" | \"shipped\";\nexport type RoadmapView = \"board\" | \"timeline\";\n\nexport interface RoadmapItem {\n  id: string;\n  title: string;\n  description?: string;\n  status: RoadmapStatus;\n  tags?: string[];\n  votes: number;\n  /** The current viewer has upvoted this item. */\n  voted?: boolean;\n  /** Timeline bucket, e.g. \"Q4 2026\". Items without one land in \"Later\". */\n  quarter?: string;\n}\n\nexport interface RoadmapProps {\n  items?: RoadmapItem[];\n  defaultItems?: RoadmapItem[];\n  onItemsChange?: (items: RoadmapItem[]) => void;\n  view?: RoadmapView;\n  defaultView?: RoadmapView;\n  onViewChange?: (view: RoadmapView) => void;\n  /** Visible statuses (the status filter). Default: all three. */\n  statuses?: RoadmapStatus[];\n  defaultStatuses?: RoadmapStatus[];\n  onStatusesChange?: (statuses: RoadmapStatus[]) => void;\n  /** Fires after the optimistic vote toggle. */\n  onVote?: (id: string, voted: boolean) => void;\n  /** Quarter order for the timeline view; defaults to first-seen order. */\n  quarters?: string[];\n  className?: string;\n}\n\nconst STATUSES: RoadmapStatus[] = [\"planned\", \"in-progress\", \"shipped\"];\n\nconst STATUS_LABEL: Record<RoadmapStatus, string> = {\n  planned: \"Planned\",\n  \"in-progress\": \"In progress\",\n  shipped: \"Shipped\",\n};\n\nconst STATUS_DOT: Record<RoadmapStatus, string> = {\n  planned: \"bg-chart-2\",\n  \"in-progress\": \"bg-warning\",\n  shipped: \"bg-success\",\n};\n\nconst STATUS_BADGE: Record<RoadmapStatus, BadgeVariant> = {\n  planned: \"secondary\",\n  \"in-progress\": \"warning\",\n  shipped: \"success\",\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\nconst byVotes = (a: RoadmapItem, b: RoadmapItem) => b.votes - a.votes;\n\nexport function Roadmap({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  view: viewProp,\n  defaultView = \"board\",\n  onViewChange,\n  statuses: statusesProp,\n  defaultStatuses = STATUSES,\n  onStatusesChange,\n  onVote,\n  quarters: quartersProp,\n  className,\n}: RoadmapProps) {\n  const uid = useId();\n  const reduce = useReducedMotion() ?? false;\n  const [items, setItems] = useControllable(itemsProp, defaultItems, onItemsChange);\n  const [view, setView] = useControllable(viewProp, defaultView, onViewChange);\n  const [statuses, setStatuses] = useControllable(statusesProp, defaultStatuses, onStatusesChange);\n\n  const visible = useMemo(() => items.filter((i) => statuses.includes(i.status)), [items, statuses]);\n\n  const vote = (id: string) => {\n    let next = false;\n    setItems(\n      items.map((item) => {\n        if (item.id !== id) return item;\n        next = !item.voted;\n        return { ...item, voted: next, votes: item.votes + (next ? 1 : -1) };\n      }),\n    );\n    onVote?.(id, next);\n  };\n\n  const quarters = useMemo(() => {\n    const seen = quartersProp ? [...quartersProp] : [];\n    for (const i of visible) {\n      const q = i.quarter ?? \"Later\";\n      if (!seen.includes(q)) seen.push(q);\n    }\n    return seen.filter((q) => visible.some((i) => (i.quarter ?? \"Later\") === q));\n  }, [visible, quartersProp]);\n\n  const visibleStatuses = STATUSES.filter((s) => statuses.includes(s));\n\n  return (\n    <div className={cn(\"@container flex w-full flex-col gap-4 text-sm\", className)}>\n      <div className=\"flex flex-wrap items-center justify-between gap-2\">\n        <ToggleGroup\n          type=\"multiple\"\n          variant=\"outline\"\n          size=\"sm\"\n          aria-label=\"Filter by status\"\n          value={statuses}\n          onValueChange={(v) => setStatuses(STATUSES.filter((s) => v.includes(s)))}\n        >\n          {STATUSES.map((s) => (\n            <ToggleGroupItem key={s} value={s}>\n              <span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", STATUS_DOT[s])} />\n              {STATUS_LABEL[s]}\n            </ToggleGroupItem>\n          ))}\n        </ToggleGroup>\n        <SegmentedControl\n          aria-label=\"View\"\n          size=\"sm\"\n          value={view}\n          onValueChange={(v) => setView(v as RoadmapView)}\n          options={[\n            { value: \"board\", label: \"Board\", icon: <Columns3 aria-hidden=\"true\" /> },\n            { value: \"timeline\", label: \"Timeline\", icon: <ListTree aria-hidden=\"true\" /> },\n          ]}\n        />\n      </div>\n\n      {visible.length === 0 ? (\n        <EmptyState size=\"sm\" bordered title=\"Nothing to show\" description=\"Turn on a status to see its items.\" />\n      ) : (\n        // Cards share a layoutId across views, so switching Board ↔ Timeline\n        // flies each card to its new slot instead of re-rendering the list.\n        <LayoutGroup id={uid}>\n          {view === \"board\" ? (\n            <div\n              className={cn(\n                \"grid gap-3\",\n                visibleStatuses.length === 3 && \"@lg:grid-cols-3\",\n                visibleStatuses.length === 2 && \"@md:grid-cols-2\",\n              )}\n            >\n              {visibleStatuses.map((status) => {\n                const column = visible.filter((i) => i.status === status).sort(byVotes);\n                return (\n                  <section\n                    key={status}\n                    aria-labelledby={`${uid}-${status}`}\n                    className=\"flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-muted/40 p-2\"\n                  >\n                    <h3\n                      id={`${uid}-${status}`}\n                      className=\"flex items-center gap-2 px-1.5 pt-1 text-xs font-medium text-muted-foreground\"\n                    >\n                      <span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", STATUS_DOT[status])} />\n                      {STATUS_LABEL[status]}\n                      <span className=\"ml-auto font-mono tabular-nums\">{column.length}</span>\n                    </h3>\n                    <ul className=\"flex flex-col gap-2\">\n                      <AnimatePresence initial={false} mode=\"popLayout\">\n                        {column.map((item) => (\n                          <RoadmapCard key={item.id} uid={uid} item={item} reduce={reduce} onVote={vote} />\n                        ))}\n                      </AnimatePresence>\n                    </ul>\n                  </section>\n                );\n              })}\n            </div>\n          ) : (\n            <ol className=\"flex flex-col gap-4\">\n              {quarters.map((quarter, qi) => (\n                <li key={quarter} className=\"relative grid grid-cols-[4.5rem_1fr] gap-3\">\n                  <div className=\"relative pt-2 text-right\">\n                    <span className=\"font-mono text-xs font-semibold text-foreground\">{quarter}</span>\n                    {qi < quarters.length - 1 && (\n                      <span aria-hidden=\"true\" className=\"absolute -right-[7px] top-6 -bottom-6 w-px bg-border\" />\n                    )}\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute -right-[11px] top-3 size-2 rounded-full bg-foreground/40 ring-4 ring-background\"\n                    />\n                  </div>\n                  <ul className=\"flex flex-col gap-2 pl-2\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      {visible\n                        .filter((i) => (i.quarter ?? \"Later\") === quarter)\n                        .sort(\n                          (a, b) => STATUSES.indexOf(b.status) - STATUSES.indexOf(a.status) || byVotes(a, b),\n                        )\n                        .map((item) => (\n                          <RoadmapCard key={item.id} uid={uid} item={item} reduce={reduce} onVote={vote} showStatus />\n                        ))}\n                    </AnimatePresence>\n                  </ul>\n                </li>\n              ))}\n            </ol>\n          )}\n        </LayoutGroup>\n      )}\n    </div>\n  );\n}\n\nfunction RoadmapCard({\n  uid,\n  item,\n  reduce,\n  onVote,\n  showStatus,\n}: {\n  uid: string;\n  item: RoadmapItem;\n  reduce: boolean;\n  onVote: (id: string) => void;\n  showStatus?: boolean;\n}) {\n  return (\n    <motion.li\n      layoutId={reduce ? undefined : `${uid}-card-${item.id}`}\n      layout={!reduce}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, transition: { duration: 0.12 } }}\n      transition={SPRING_LAYOUT}\n      className=\"flex gap-3 rounded-lg border border-border bg-card p-3 shadow-sm\"\n    >\n      <div className=\"min-w-0 flex-1\">\n        {showStatus && (\n          <Badge size=\"sm\" variant={STATUS_BADGE[item.status]} dot className=\"mb-1.5\">\n            {STATUS_LABEL[item.status]}\n          </Badge>\n        )}\n        <p className=\"font-medium leading-snug text-foreground\">{item.title}</p>\n        {item.description && (\n          <p className=\"mt-1 line-clamp-2 text-xs text-muted-foreground\">{item.description}</p>\n        )}\n        {item.tags?.length ? (\n          <div className=\"mt-2 flex flex-wrap gap-1\">\n            {item.tags.map((tag) => (\n              <Badge key={tag} size=\"sm\" variant=\"outline\">\n                {tag}\n              </Badge>\n            ))}\n          </div>\n        ) : null}\n      </div>\n      <UpvoteButton count={item.votes} voted={!!item.voted} title={item.title} reduce={reduce} onClick={() => onVote(item.id)} />\n    </motion.li>\n  );\n}\n\nconst ROLL: Variants = {\n  enter: (d: number) => ({ y: d > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (d: number) => ({ y: d > 0 ? \"-100%\" : \"100%\", opacity: 0 }),\n};\nconst FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };\n\nfunction UpvoteButton({\n  count,\n  voted,\n  title,\n  reduce,\n  onClick,\n}: {\n  count: number;\n  voted: boolean;\n  title: string;\n  reduce: boolean;\n  onClick: () => void;\n}) {\n  // Direction is derived during render from the last seen count (React's\n  // \"adjust state on prop change\" pattern), so rolls go up on +1, down on -1.\n  const [last, setLast] = useState(count);\n  const [direction, setDirection] = useState(1);\n  if (count !== last) {\n    setDirection(count > last ? 1 : -1);\n    setLast(count);\n  }\n  const variants = reduce ? FADE : ROLL;\n\n  return (\n    <motion.button\n      type=\"button\"\n      aria-pressed={voted}\n      aria-label={`Upvote ${title}, ${count} vote${count === 1 ? \"\" : \"s\"}`}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"flex h-12 w-10 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-border text-xs font-semibold outline-none transition-colors\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n        voted\n          ? \"border-primary bg-primary text-primary-foreground\"\n          : \"border-border bg-background text-foreground hover:border-border-strong hover:bg-muted\",\n      )}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        animate={voted && !reduce ? { y: [0, -3, 0] } : { y: 0 }}\n        transition={{ duration: 0.3, ease: EASE_OUT }}\n        className=\"flex\"\n      >\n        <ChevronUp className=\"size-4\" />\n      </motion.span>\n      <span aria-hidden=\"true\" className=\"relative flex h-4 overflow-hidden font-mono tabular-nums\">\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n          <motion.span\n            key={count}\n            custom={direction}\n            variants={variants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={SPRING_SWAP}\n          >\n            {count}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/badge.tsx","type":"registry:component","target":"@components/motion/badge.tsx","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  PresenceContext,\n  useAnimate,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BadgeVariant =\n  | \"default\"\n  | \"secondary\"\n  | \"outline\"\n  | \"destructive\"\n  | \"success\"\n  | \"warning\";\n\nexport type BadgeSize = \"sm\" | \"md\" | \"lg\";\n\n/** Soft tinted surface or a filled one. Only the status variants differ. */\nexport type BadgeTone = \"soft\" | \"solid\";\n\nconst BASE: Record<\"default\" | \"secondary\" | \"outline\", string> = {\n  default: \"border-transparent bg-primary text-primary-foreground\",\n  secondary: \"border-transparent bg-foreground/[0.07] text-foreground\",\n  outline: \"border-border-strong bg-transparent text-foreground\",\n};\n\n// Status tints mix the token toward --foreground/--background (as `alert`\n// does), so the text keeps its contrast in both themes and every design style.\nconst SOFT: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]\",\n  success:\n    \"border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]\",\n  warning:\n    \"border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]\",\n};\n\n// Filled status badges. Red reads best with light text in both themes;\n// green and amber are light hues, so they always get dark text.\nconst SOLID: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-transparent bg-destructive text-background dark:text-foreground\",\n  success:\n    \"border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]\",\n  warning:\n    \"border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]\",\n};\n\nconst SIZE: Record<BadgeSize, string> = {\n  sm: \"h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3\",\n  md: \"h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  lg: \"h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5\",\n};\n\n// Count badges are circles at one digit and pills beyond.\nconst COUNT_SIZE: Record<BadgeSize, string> = {\n  sm: \"h-4 min-w-4 px-1 text-[10px]\",\n  md: \"h-5 min-w-5 px-1.5 text-[11px]\",\n  lg: \"h-6 min-w-6 px-1.5 text-xs\",\n};\n\nfunction variantClass(variant: BadgeVariant, tone: BadgeTone) {\n  if (variant === \"default\" || variant === \"secondary\" || variant === \"outline\") {\n    return BASE[variant];\n  }\n  return tone === \"solid\" ? SOLID[variant] : SOFT[variant];\n}\n\nexport interface BadgeProps {\n  children?: ReactNode;\n  variant?: BadgeVariant;\n  /** Status variants only (destructive/success/warning). Default \"soft\". */\n  tone?: BadgeTone;\n  size?: BadgeSize;\n  /** Leading status dot in the text color; `\"pulse\"` adds a radiating ring. */\n  dot?: boolean | \"pulse\";\n  /** Leading icon (sized to the badge). Ignored when `dot` is set. */\n  icon?: ReactNode;\n  /**\n   * Makes the badge removable: an × button plays the exit, then calls this.\n   * Focus moves to the neighbouring chip's × when the removal came from it.\n   */\n  onRemove?: () => void;\n  /** Accessible name of the × button. Default `Remove <text>`. */\n  removeLabel?: string;\n  /**\n   * Count mode: renders a compact number that rolls up or down as it changes\n   * and bumps when it rises. Hidden at 0 unless `showZero`.\n   */\n  count?: number;\n  /** Counts above this read \"max+\". Default 99. */\n  max?: number;\n  showZero?: boolean;\n  /** Spoken label for count mode, e.g. `(n) => \\`${n} unread\\``. */\n  countLabel?: (count: number) => string;\n  className?: string;\n}\n\n/**\n * General-purpose badge: six variants, three sizes, an optional (pulsing) dot\n * or icon, removable chips with an exit animation, and a count mode with\n * rolling digits capped at \"99+\". For animated status transitions\n * (loading → success) use `AnimatedBadge` instead.\n */\nexport function Badge(props: BadgeProps) {\n  if (props.count !== undefined) return <CountBadge {...props} count={props.count} />;\n  return <LabelBadge {...props} />;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Label badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction LabelBadge({\n  children,\n  variant = \"default\",\n  tone = \"soft\",\n  size = \"md\",\n  dot = false,\n  icon,\n  onRemove,\n  removeLabel,\n  className,\n}: BadgeProps) {\n  const reduce = useReducedMotion() ?? false;\n  // Inside an AnimatePresence the badge pops in when added; anywhere else it\n  // renders as-is so server HTML is visible before hydration.\n  const inPresence = useContext(PresenceContext) !== null;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const [leaving, setLeaving] = useState(false);\n  const refocus = useRef<HTMLElement | null>(null);\n  const removable = Boolean(onRemove);\n\n  const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 };\n\n  const startRemove = (fromKeyboardFocus: boolean) => {\n    if (leaving) return;\n    if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current);\n    setLeaving(true);\n  };\n\n  const finishRemove = () => {\n    if (!leaving) return;\n    const target = refocus.current;\n    refocus.current = null;\n    onRemove?.();\n    if (target) requestAnimationFrame(() => target.focus({ preventScroll: true }));\n  };\n\n  const text = typeof children === \"string\" ? children : undefined;\n\n  return (\n    <motion.span\n      ref={rootRef}\n      data-badge=\"\"\n      // Siblings glide into the gap a removed chip leaves (transform-only).\n      layout={removable && !reduce ? \"position\" : false}\n      initial={inPresence ? hidden : false}\n      animate={\n        leaving\n          ? { ...hidden, pointerEvents: \"none\" }\n          : { opacity: 1, scale: 1, pointerEvents: \"auto\" }\n      }\n      // Already faded out by the × — leave at once so siblings close the gap.\n      exit={{\n        ...hidden,\n        pointerEvents: \"none\",\n        transition: { duration: leaving ? 0 : 0.12, ease: EASE_OUT },\n      }}\n      transition={\n        leaving\n          ? { duration: 0.14, ease: EASE_OUT }\n          : reduce\n            ? { duration: 0.12 }\n            : { ...SPRING_PANEL, layout: SPRING_LAYOUT }\n      }\n      onAnimationComplete={finishRemove}\n      className={cn(\n        \"inline-flex shrink-0 select-none items-center whitespace-nowrap rounded-full border font-medium leading-none\",\n        SIZE[size],\n        variantClass(variant, tone),\n        removable && (size === \"sm\" ? \"pr-0.5\" : \"pr-1\"),\n        className,\n      )}\n    >\n      {dot ? (\n        <Dot pulse={dot === \"pulse\" && !reduce} />\n      ) : icon ? (\n        <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {removable ? (\n        <button\n          type=\"button\"\n          data-badge-remove=\"\"\n          aria-label={removeLabel ?? (text ? `Remove ${text}` : \"Remove\")}\n          onClick={(event) => startRemove(event.detail === 0)}\n          onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {\n            if (event.key === \"Backspace\" || event.key === \"Delete\") {\n              event.preventDefault();\n              startRemove(true);\n            }\n          }}\n          className={cn(\n            \"inline-flex shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] hover:bg-current/15 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40\",\n            size === \"sm\" ? \"size-4\" : \"size-[18px]\",\n          )}\n        >\n          <X aria-hidden=\"true\" strokeWidth={2.4} className=\"size-3!\" />\n        </button>\n      ) : null}\n    </motion.span>\n  );\n}\n\n/** The × of the next chip in the row, else the previous one. */\nfunction neighbourRemoveButton(node: HTMLElement | null) {\n  if (!node) return null;\n  const pick = (el: Element | null) =>\n    el?.querySelector<HTMLElement>(\"[data-badge-remove]\") ?? null;\n  let next = node.nextElementSibling;\n  while (next && !pick(next)) next = next.nextElementSibling;\n  if (next) return pick(next);\n  let prev = node.previousElementSibling;\n  while (prev && !pick(prev)) prev = prev.previousElementSibling;\n  return prev ? pick(prev) : null;\n}\n\n// One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose:\n// an ambient \"live\" signal, not feedback, so it sits well outside UI timing.\nconst PULSE_DURATION = 1.6;\n\nfunction Dot({ pulse }: { pulse: boolean }) {\n  return (\n    <span aria-hidden=\"true\" className=\"relative inline-flex size-1.5 shrink-0\">\n      {pulse ? (\n        <motion.span\n          className=\"absolute inset-0 rounded-full bg-current\"\n          initial={{ scale: 1, opacity: 0.55 }}\n          animate={{ scale: 2.6, opacity: 0 }}\n          transition={{\n            duration: PULSE_DURATION,\n            ease: EASE_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n          }}\n        />\n      ) : null}\n      <span className=\"relative size-1.5 rounded-full bg-current\" />\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Count badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction CountBadge({\n  count,\n  max = 99,\n  showZero = false,\n  countLabel,\n  variant = \"destructive\",\n  tone = \"solid\",\n  size = \"md\",\n  className,\n}: BadgeProps & { count: number }) {\n  const reduce = useReducedMotion() ?? false;\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const value = Math.max(0, Math.round(count));\n  const visible = value > 0 || showZero;\n  const text = value > max ? `${max}+` : String(value);\n\n  // Direction is derived during render (not in an effect) so the very render\n  // that swaps the digits already rolls them the right way.\n  const [seen, setSeen] = useState(value);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  if (value !== seen) {\n    setSeen(value);\n    setDirection(value > seen ? 1 : -1);\n  }\n\n  // Rising counts bump the pill (not on the pop-in from zero, which has its\n  // own entrance).\n  const previous = useRef(value);\n  useEffect(() => {\n    const prev = previous.current;\n    previous.current = value;\n    if (value > prev && prev > 0 && !reduce && scope.current) {\n      animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.32, ease: EASE_OUT });\n    }\n  }, [value, reduce, animate, scope]);\n\n  return (\n    <AnimatePresence initial={false}>\n      {visible ? (\n        <motion.span\n          key=\"count\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0, transition: { duration: 0.1 } }\n              : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: EASE_OUT } }\n          }\n          transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n          className={cn(\"inline-flex shrink-0\", className)}\n        >\n          <span\n            ref={scope}\n            data-badge-count=\"\"\n            className={cn(\n              \"inline-flex items-center justify-center rounded-full border font-semibold leading-none tabular-nums\",\n              COUNT_SIZE[size],\n              variantClass(variant, tone),\n            )}\n          >\n            <span className=\"sr-only\">{countLabel ? countLabel(value) : text}</span>\n            <RollingCount text={text} direction={direction} reduce={reduce} />\n          </span>\n        </motion.span>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nconst roll: Variants = {\n  enter: (dir: 1 | -1) => ({ y: dir > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: 1 | -1) => ({\n    y: dir > 0 ? \"-100%\" : \"100%\",\n    opacity: 0,\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/**\n * Direction-aware digit roll. `NumberTicker` spins each digit along a 0–9\n * strip, so 9 → 10 would roll the ones digit backwards; here every changed\n * glyph slides in from the direction of change instead.\n */\nfunction RollingCount({\n  text,\n  direction,\n  reduce,\n}: {\n  text: string;\n  direction: 1 | -1;\n  reduce: boolean;\n}) {\n  const chars = text.split(\"\");\n  // Keyed by place value from the right so a digit keeps its slot as the\n  // number grows on the left.\n  const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex\">\n      {glyphs.map(({ char, place }) => (\n        <span\n          key={place}\n          className=\"relative inline-flex h-[1.2em] items-center justify-center overflow-hidden\"\n        >\n          <span className=\"invisible\">{char}</span>\n          <AnimatePresence initial={false} custom={direction}>\n            <motion.span\n              key={char}\n              custom={direction}\n              variants={reduce ? fade : roll}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n"},{"path":"components/motion/empty-state.tsx","type":"registry:component","target":"@components/motion/empty-state.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type EmptyStateSize = \"sm\" | \"md\" | \"lg\";\nexport type EmptyStateIllustrationVariant = \"documents\" | \"search\";\n\n/* -------------------------------------------------------------------------- */\n/*                                Illustration                                */\n/* -------------------------------------------------------------------------- */\n\nexport interface EmptyStateIllustrationProps {\n  variant?: EmptyStateIllustrationVariant;\n  /** Width in px; height follows the 5:4 viewBox. */\n  size?: number;\n  className?: string;\n}\n\nconst draw = (delay: number, reduce: boolean) => ({\n  initial: reduce ? { pathLength: 1, opacity: 0 } : { pathLength: 0, opacity: 0 },\n  whileInView: { pathLength: 1, opacity: 1 },\n  viewport: { once: true },\n  transition: reduce\n    ? { duration: 0.2 }\n    : {\n        pathLength: { duration: 0.5, ease: EASE_OUT, delay },\n        opacity: { duration: 0.1, delay },\n      },\n});\n\n// Idle float: translate only (no rotate/scale), so no SVG transform-origin is\n// involved. Rotations in the art are static `transform` attributes on an\n// inner <g> that motion never touches.\nconst float = (reduce: boolean, amplitude: number, duration: number, delay: number) =>\n  reduce\n    ? {}\n    : {\n        animate: { y: [0, -amplitude, 0] },\n        transition: { duration, ease: EASE_IN_OUT, repeat: Infinity, delay },\n      };\n\n/** Small default empty-state art: a floating document stack or a search lens. */\nexport function EmptyStateIllustration({\n  variant = \"documents\",\n  size = 120,\n  className,\n}: EmptyStateIllustrationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const lineClass = \"stroke-border-strong\";\n\n  return (\n    <svg\n      width={size}\n      height={(size * 4) / 5}\n      viewBox=\"0 0 120 96\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className={cn(\"overflow-visible\", className)}\n    >\n      <ellipse cx=\"60\" cy=\"88\" rx=\"30\" ry=\"3.5\" className=\"fill-muted\" />\n      {variant === \"documents\" ? (\n        <>\n          <motion.g {...float(reduce, 2, 3.4, 0.4)}>\n            <g transform=\"rotate(-9 46 48)\">\n              <rect x=\"24\" y=\"20\" width=\"44\" height=\"56\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            </g>\n          </motion.g>\n          <motion.g {...float(reduce, 2.5, 3.8, 0.9)}>\n            <g transform=\"rotate(8 74 48)\">\n              <rect x=\"52\" y=\"20\" width=\"44\" height=\"56\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            </g>\n          </motion.g>\n          <motion.g {...float(reduce, 3.5, 3, 0)}>\n            <rect x=\"37\" y=\"16\" width=\"46\" height=\"60\" rx=\"7\" strokeWidth=\"1.5\" className=\"fill-card stroke-border-strong\" />\n            <motion.path d=\"M46 30h20\" strokeWidth=\"3\" strokeLinecap=\"round\" className=\"stroke-foreground/70\" {...draw(0.25, reduce)} />\n            <motion.path d=\"M46 40h28\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.35, reduce)} />\n            <motion.path d=\"M46 49h24\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.45, reduce)} />\n            <motion.path d=\"M46 58h16\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.55, reduce)} />\n            <motion.g\n              initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n              whileInView={{ opacity: 1, y: 0 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.6 }}\n            >\n              <circle cx=\"84\" cy=\"17\" r=\"9\" className=\"fill-primary\" />\n              <path d=\"M84 13v8M80 17h8\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"stroke-primary-foreground\" />\n            </motion.g>\n          </motion.g>\n        </>\n      ) : (\n        <>\n          <motion.g {...float(reduce, 1.5, 3.6, 0.5)}>\n            <rect x=\"22\" y=\"22\" width=\"64\" height=\"50\" rx=\"8\" strokeWidth=\"1.5\" className=\"fill-muted stroke-border-strong\" />\n            <motion.path d=\"M32 36h30\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.2, reduce)} />\n            <motion.path d=\"M32 46h22\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.3, reduce)} />\n            <motion.path d=\"M32 56h26\" strokeWidth=\"3\" strokeLinecap=\"round\" className={lineClass} {...draw(0.4, reduce)} />\n          </motion.g>\n          <motion.g {...float(reduce, 3.5, 3, 0)}>\n            <circle cx=\"76\" cy=\"48\" r=\"17\" strokeWidth=\"1.5\" className=\"fill-card stroke-border-strong\" />\n            <motion.circle cx=\"76\" cy=\"48\" r=\"11\" strokeWidth=\"3\" className=\"stroke-foreground/70\" {...draw(0.35, reduce)} />\n            <motion.path d=\"M84.5 56.5 96 68\" strokeWidth=\"5\" strokeLinecap=\"round\" className=\"stroke-foreground/70\" {...draw(0.6, reduce)} />\n            <motion.path d=\"M72.5 44.5l7 7M79.5 44.5l-7 7\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"stroke-muted-foreground\" {...draw(0.75, reduce)} />\n          </motion.g>\n        </>\n      )}\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 EmptyState                                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface EmptyStateProps {\n  title: ReactNode;\n  description?: ReactNode;\n  /**\n   * Icon or illustration slot. Defaults to `<EmptyStateIllustration />`;\n   * pass `null` for none.\n   */\n  icon?: ReactNode;\n  /** Primary call to action (e.g. a library `Button`). */\n  action?: ReactNode;\n  /** Secondary call to action, rendered after the primary. */\n  secondaryAction?: ReactNode;\n  size?: EmptyStateSize;\n  /** Dashed border container, for empty regions inside a page. */\n  bordered?: boolean;\n  /** Heading element for the title. Default `h3`. */\n  titleAs?: \"h2\" | \"h3\" | \"h4\" | \"p\";\n  /** Extra content under the actions (links, hints). */\n  children?: ReactNode;\n  className?: string;\n}\n\nconst SIZE: Record<\n  EmptyStateSize,\n  { root: string; title: string; description: string; art: number }\n> = {\n  sm: { root: \"gap-3 px-4 py-6\", title: \"text-sm\", description: \"text-xs max-w-[32ch]\", art: 88 },\n  md: { root: \"gap-4 px-6 py-10\", title: \"text-base\", description: \"text-sm max-w-[38ch]\", art: 120 },\n  lg: { root: \"gap-5 px-8 py-14\", title: \"text-lg\", description: \"text-base max-w-[44ch]\", art: 152 },\n};\n\n/**\n * Empty-state block: illustration, title, description and actions that\n * stagger in when scrolled into view (opacity + small rise; opacity only under\n * reduced motion).\n */\nexport function EmptyState({\n  title,\n  description,\n  icon,\n  action,\n  secondaryAction,\n  size = \"md\",\n  bordered = false,\n  titleAs: Title = \"h3\",\n  children,\n  className,\n}: EmptyStateProps) {\n  const reduce = useReducedMotion();\n  const s = SIZE[size];\n\n  const container: Variants = {\n    hidden: {},\n    show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },\n  };\n  const item: Variants = {\n    hidden: { opacity: 0, y: reduce ? 0 : 8 },\n    show: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT } },\n  };\n\n  const art = icon === undefined ? <EmptyStateIllustration size={s.art} /> : icon;\n\n  return (\n    <motion.div\n      variants={container}\n      initial=\"hidden\"\n      whileInView=\"show\"\n      viewport={{ once: true, amount: 0.4 }}\n      className={cn(\n        \"flex w-full flex-col items-center text-center\",\n        s.root,\n        bordered && \"rounded-2xl border border-dashed border-border-strong\",\n        className,\n      )}\n    >\n      {art != null ? (\n        <motion.div variants={item} className=\"flex items-center justify-center text-muted-foreground\">\n          {art}\n        </motion.div>\n      ) : null}\n      <div className=\"flex flex-col items-center gap-1.5\">\n        <motion.div variants={item}>\n          <Title className={cn(\"font-semibold tracking-tight text-foreground\", s.title)}>\n            {title}\n          </Title>\n        </motion.div>\n        {description != null ? (\n          <motion.p variants={item} className={cn(\"text-muted-foreground text-balance\", s.description)}>\n            {description}\n          </motion.p>\n        ) : null}\n      </div>\n      {action != null || secondaryAction != null ? (\n        <motion.div variants={item} className=\"flex flex-wrap items-center justify-center gap-2\">\n          {action}\n          {secondaryAction}\n        </motion.div>\n      ) : null}\n      {children != null ? <motion.div variants={item}>{children}</motion.div> : null}\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/segmented-control.tsx","type":"registry:component","target":"@components/motion/segmented-control.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)\n// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form\n// control: it picks one *value* with radiogroup/radio semantics, can post that\n// value with a form via `name`, supports disabled options, icon-only options\n// and a full-width layout, and its thumb is a raised card surface rather than\n// a filled accent pill. Reach for Tabs when each option owns content below it.\n\nexport type SegmentedControlSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface SegmentedControlOption {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the option has only an icon. */\n  \"aria-label\"?: string;\n}\n\nconst trackSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-8 p-0.5 text-xs\",\n  md: \"h-10 p-1 text-sm\",\n  lg: \"h-12 p-1 text-sm\",\n};\n\nconst itemSize: Record<SegmentedControlSize, string> = {\n  sm: \"gap-1.5 px-2.5 [&_svg]:size-3.5\",\n  md: \"gap-2 px-3.5 [&_svg]:size-4\",\n  lg: \"gap-2 px-5 [&_svg]:size-[18px]\",\n};\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(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\n/** Index of the next enabled option from `from` in `dir`, wrapping around. */\nfunction nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {\n  const n = options.length;\n  for (let step = 1; step <= n; step++) {\n    const i = (((from + dir * step) % n) + n) % n;\n    if (!options[i].disabled) return i;\n  }\n  return from;\n}\n\nexport interface SegmentedControlProps {\n  options: SegmentedControlOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  size?: SegmentedControlSize;\n  /** Stretch to the container width with equal-width segments. */\n  fullWidth?: boolean;\n  disabled?: boolean;\n  /** When set, a hidden input posts the value with the surrounding form. */\n  name?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n  thumbClassName?: string;\n}\n\nexport function SegmentedControl({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  size = \"md\",\n  fullWidth = false,\n  disabled = false,\n  name,\n  className,\n  thumbClassName,\n  ...aria\n}: SegmentedControlProps) {\n  const [current, setValue] = useControllable(\n    value,\n    defaultValue ?? options.find((o) => !o.disabled)?.value ?? \"\",\n    onValueChange,\n  );\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));\n  const selectedIndex = items.findIndex((o) => o.value === current);\n  // Roving tab stop: the checked radio, or the first enabled one when none is.\n  const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled\n    ? selectedIndex\n    : items.findIndex((o) => !o.disabled);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        next = nextEnabled(items, index, 1);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        next = nextEnabled(items, index, -1);\n        break;\n      case \"Home\":\n        next = nextEnabled(items, -1, 1);\n        break;\n      case \"End\":\n        next = nextEnabled(items, items.length, -1);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    if (next === null || items[next].disabled) return;\n    // Selection follows focus, as the WAI-ARIA radio group pattern prescribes.\n    refs.current[next]?.focus();\n    if (items[next].value !== current) setValue(items[next].value);\n  };\n\n  return (\n    // layoutRoot keeps the thumb's shared-layout projection local, so scrolled\n    // or fixed ancestors never leak offsets into its glide.\n    <motion.div\n      layoutRoot\n      role=\"radiogroup\"\n      aria-disabled={disabled || undefined}\n      {...aria}\n      className={cn(\n        \"relative isolate items-stretch rounded-xl border border-border bg-muted\",\n        fullWidth ? \"flex w-full\" : \"inline-flex\",\n        trackSize[size],\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {name ? <input type=\"hidden\" name={name} value={current} /> : null}\n      {items.map((option, index) => {\n        const checked = option.value === current;\n        const iconOnly = !option.label;\n        return (\n          <motion.button\n            key={option.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={option[\"aria-label\"]}\n            disabled={option.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={checked ? \"checked\" : \"unchecked\"}\n            onClick={() => !checked && setValue(option.value)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              fullWidth && \"flex-1\",\n              itemSize[size],\n              iconOnly && \"aspect-square px-0\",\n              checked\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:text-foreground\",\n            )}\n          >\n            {checked ? (\n              <motion.span\n                layoutId={layoutId}\n                aria-hidden\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className={cn(\n                  \"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm\",\n                  thumbClassName,\n                )}\n              />\n            ) : null}\n            {option.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {option.icon}\n              </span>\n            ) : null}\n            {option.label ? <span className=\"truncate\">{option.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleGroupItem {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the item has only an icon. */\n  \"aria-label\"?: string;\n}\n\ntype ToggleGroupBase = {\n  items: ToggleGroupItem[];\n  size?: SegmentedControlSize;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst toggleSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const { items, size = \"md\", disabled = false, className } = props;\n  const reduce = useReducedMotion();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n\n  // Normalise both modes to a string[] internally.\n  const single = props.type === \"single\";\n  const [current, setCurrent] = useControllable<string[]>(\n    props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),\n    single\n      ? props.defaultValue\n        ? [props.defaultValue as string]\n        : []\n      : ((props.defaultValue as string[] | undefined) ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));\n  const firstEnabled = list.findIndex((i) => !i.disabled);\n  const tabStop =\n    focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;\n\n  const toggle = (itemValue: string) => {\n    const pressed = current.includes(itemValue);\n    if (props.type === \"single\") {\n      if (pressed && props.required) return;\n      setCurrent(pressed ? [] : [itemValue]);\n    } else {\n      setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = nextEnabled(list, index, 1);\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = nextEnabled(list, index, -1);\n    else if (event.key === \"Home\") next = nextEnabled(list, -1, 1);\n    else if (event.key === \"End\") next = nextEnabled(list, list.length, -1);\n    if (next === null) return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).\n    setFocusIndex(next);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-label={props[\"aria-label\"]}\n      aria-labelledby={props[\"aria-labelledby\"]}\n      className={cn(\n        \"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {list.map((item, index) => {\n        const pressed = current.includes(item.value);\n        return (\n          <motion.button\n            key={item.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            aria-pressed={pressed}\n            aria-label={item[\"aria-label\"]}\n            disabled={item.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={pressed ? \"on\" : \"off\"}\n            onClick={() => {\n              setFocusIndex(index);\n              toggle(item.value);\n            }}\n            onFocus={() => setFocusIndex(index)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              toggleSize[size],\n              pressed\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false}>\n              {pressed ? (\n                <motion.span\n                  key=\"on\"\n                  aria-hidden\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}\n                  transition={SPRING_SWAP}\n                  className=\"absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong\"\n                />\n              ) : null}\n            </AnimatePresence>\n            {item.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {item.icon}\n              </span>\n            ) : null}\n            {item.label ? <span>{item.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/toggle-group.tsx","type":"registry:component","target":"@components/motion/toggle-group.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  createContext,\n  forwardRef,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`\n// (segmented-control.tsx) is a radiogroup form control that always holds one\n// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This\n// file is the composable, shadcn-shaped toolbar primitive: a standalone\n// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children\n// (icons, labels, tooltips, anything), with `default`/`outline` variants. In\n// single mode one pressed surface glides between items; in multiple mode each\n// item pops its own surface in and out.\n\nexport type ToggleVariant = \"default\" | \"outline\";\nexport type ToggleSize = \"sm\" | \"md\" | \"lg\";\n\nconst sizeClass: Record<ToggleSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nconst surfaceClass: Record<ToggleVariant, string> = {\n  default: \"bg-muted ring-1 ring-border-strong\",\n  outline: \"bg-muted\",\n};\n\nconst itemBase =\n  \"relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\";\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(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\ntype ButtonBaseProps = Omit<HTMLMotionProps<\"button\">, \"children\" | \"value\" | \"type\">;\n\n/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */\nfunction PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {\n  const reduce = useReducedMotion();\n  return (\n    <AnimatePresence initial={false}>\n      {show ? (\n        <motion.span\n          key=\"on\"\n          aria-hidden\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }\n          }\n          transition={SPRING_SWAP}\n          className={cn(\"absolute inset-0 -z-10 rounded-[inherit]\", surfaceClass[variant])}\n        />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Toggle                                                                    */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleProps extends ButtonBaseProps {\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  children?: ReactNode;\n}\n\n/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */\nexport const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(\n  {\n    pressed,\n    defaultPressed = false,\n    onPressedChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled,\n    className,\n    children,\n    onClick,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      aria-pressed={on}\n      data-state={on ? \"on\" : \"off\"}\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOn(!on);\n      }}\n      className={cn(\n        itemBase,\n        TOUCH_TARGET_CLASS,\n        \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n        sizeClass[size],\n        variant === \"outline\" && \"border border-border\",\n        on\n          ? \"text-foreground\"\n          : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n        className,\n      )}\n      {...rest}\n    >\n      <PopSurface show={on} variant={variant} />\n      {children}\n    </motion.button>\n  );\n});\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\ninterface ToggleGroupContextValue {\n  type: \"single\" | \"multiple\";\n  pressed: string[];\n  toggle: (value: string) => void;\n  variant: ToggleVariant;\n  size: ToggleSize;\n  disabled: boolean;\n  layoutId: string;\n  tabStop: string | null;\n  setTabStop: (value: string) => void;\n}\n\nconst ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);\n\ntype ToggleGroupBase = Omit<\n  HTMLMotionProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\" | \"dir\"\n> & {\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  disabled?: boolean;\n  /** Arrow-key axis for roving focus. Default \"horizontal\" (both axes still work). */\n  orientation?: \"horizontal\" | \"vertical\";\n  children?: ReactNode;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst ITEM_SELECTOR = \"[data-toggle-group-item]\";\n\nfunction enabledItems(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(\n    (el) => !el.disabled && el.closest(\"[data-toggle-group]\") === root,\n  );\n}\n\n/**\n * Toolbar-style group of `ToggleGroupItem`s. `type=\"single\"` behaves like a\n * set of mutually exclusive toggles (deselectable unless `required`) whose\n * pressed surface glides between items; `type=\"multiple\"` presses any subset.\n * One tab stop, arrow keys / Home / End rove between items, Space/Enter press.\n */\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const {\n    type,\n    value: _value,\n    defaultValue: _defaultValue,\n    onValueChange: _onValueChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled = false,\n    orientation = \"horizontal\",\n    className,\n    children,\n    onKeyDown,\n    ...rest\n  } = props;\n  // `required` only exists on the single variant; keep it off the DOM.\n  const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };\n  const single = props.type === \"single\";\n  const required = props.type === \"single\" && Boolean(props.required);\n\n  const [pressed, setPressed] = useControllable<string[]>(\n    props.value === undefined\n      ? undefined\n      : props.type === \"single\"\n        ? props.value\n          ? [props.value]\n          : []\n        : props.value,\n    props.type === \"single\"\n      ? props.defaultValue\n        ? [props.defaultValue]\n        : []\n      : (props.defaultValue ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const layoutId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);\n\n  // Keep exactly one reachable tab stop: if the remembered item vanished or\n  // got disabled, fall back to the first pressed item, then the first enabled.\n  useEffect(() => {\n    const items = enabledItems(rootRef.current);\n    if (items.some((el) => el.dataset.value === tabStop)) return;\n    const fallback =\n      items.find((el) => pressed.includes(el.dataset.value ?? \"\")) ?? items[0];\n    const next = fallback?.dataset.value ?? null;\n    if (next !== tabStop) setTabStop(next);\n  });\n\n  const toggle = (itemValue: string) => {\n    const isOn = pressed.includes(itemValue);\n    if (single) {\n      if (isOn && required) return;\n      setPressed(isOn ? [] : [itemValue]);\n    } else {\n      setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    const items = enabledItems(rootRef.current);\n    const index = items.indexOf(document.activeElement as HTMLButtonElement);\n    if (index < 0) return;\n    const forward = orientation === \"vertical\" ? \"ArrowDown\" : \"ArrowRight\";\n    const backward = orientation === \"vertical\" ? \"ArrowUp\" : \"ArrowLeft\";\n    let next: number;\n    if (event.key === forward || event.key === \"ArrowDown\" || event.key === \"ArrowRight\")\n      next = (index + 1) % items.length;\n    else if (event.key === backward || event.key === \"ArrowUp\" || event.key === \"ArrowLeft\")\n      next = (index - 1 + items.length) % items.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = items.length - 1;\n    else return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter — these are toggles, not radios.\n    items[next].focus();\n  };\n\n  return (\n    <ToggleGroupContext.Provider\n      value={{\n        type,\n        pressed,\n        toggle,\n        variant,\n        size,\n        disabled,\n        layoutId,\n        tabStop,\n        setTabStop,\n      }}\n    >\n      {/* layoutRoot keeps the gliding surface's projection local to the group,\n          so scrolled or transformed ancestors never skew the glide. */}\n      <motion.div\n        ref={rootRef}\n        layoutRoot\n        role=\"group\"\n        data-toggle-group=\"\"\n        data-orientation={orientation}\n        aria-disabled={disabled || undefined}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"inline-flex w-fit items-center\",\n          orientation === \"vertical\" && \"flex-col items-stretch\",\n          variant === \"default\"\n            ? \"gap-0.5 rounded-xl border border-border bg-card p-1\"\n            : \"rounded-lg\",\n          disabled && \"opacity-60\",\n          className,\n        )}\n        {...domProps}\n      >\n        {children}\n      </motion.div>\n    </ToggleGroupContext.Provider>\n  );\n}\n\nexport interface ToggleGroupItemProps extends ButtonBaseProps {\n  value: string;\n  children?: ReactNode;\n}\n\nexport const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\n  function ToggleGroupItem(\n    { value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },\n    ref,\n  ) {\n    const ctx = useContext(ToggleGroupContext);\n    if (!ctx) throw new Error(\"ToggleGroupItem must be used inside <ToggleGroup>.\");\n    const reduce = useReducedMotion();\n    const disabled = ctx.disabled || Boolean(itemDisabled);\n    const on = ctx.pressed.includes(value);\n    const outline = ctx.variant === \"outline\";\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        data-toggle-group-item=\"\"\n        data-value={value}\n        data-state={on ? \"on\" : \"off\"}\n        aria-pressed={on}\n        disabled={disabled}\n        tabIndex={ctx.tabStop === value ? 0 : -1}\n        whileTap={reduce || disabled ? undefined : { scale: 0.88 }}\n        transition={SPRING_PRESS}\n        onFocus={(event) => {\n          ctx.setTabStop(value);\n          onFocus?.(event);\n        }}\n        onClick={(event) => {\n          onClick?.(event);\n          if (event.defaultPrevented) return;\n          ctx.setTabStop(value);\n          ctx.toggle(value);\n        }}\n        className={cn(\n          itemBase,\n          TOUCH_TARGET_CLASS,\n          sizeClass[ctx.size],\n          outline\n            ? // Joined segments: neighbours overlap their shared border.\n              \"rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg\"\n            : \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n          on\n            ? \"text-foreground\"\n            : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n          className,\n        )}\n        {...rest}\n      >\n        {ctx.type === \"single\" ? (\n          on ? (\n            <motion.span\n              layoutId={ctx.layoutId}\n              aria-hidden\n              transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n              className={cn(\n                \"absolute inset-0 -z-10 rounded-[inherit]\",\n                surfaceClass[ctx.variant],\n              )}\n            />\n          ) : null\n        ) : (\n          <PopSurface show={on} variant={ctx.variant} />\n        )}\n        {children}\n      </motion.button>\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"}]}