{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"audit-trail","type":"registry:block","title":"Audit Trail","description":"Chronological activity log grouped by day with actor avatars, verb-plus-target summaries, relative times, severity and category badges, actor, action-type and search filters, entries that expand into a before/after field diff, new events sliding in at the top, and an export hook that receives the filtered rows.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/audit-trail.tsx","type":"registry:component","target":"@components/motion/audit-trail.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/audit-trail\n\nimport { ChevronDown, Download, Search, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, type Ref, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { seriesColor } from \"@/components/motion/chart/shared\";\nimport { EmptyState } from \"@/components/motion/empty-state\";\nimport { MultiSelect } from \"@/components/motion/multi-select\";\nimport { SegmentedControl } from \"@/components/motion/segmented-control\";\nimport { Skeleton } from \"@/components/motion/skeleton\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AuditSeverity = \"info\" | \"notice\" | \"warning\" | \"critical\";\nexport type AuditActionType = \"create\" | \"update\" | \"delete\" | \"access\";\n\nexport interface AuditActor {\n  id: string;\n  name: string;\n  /** Avatar image URL. Initials on a categorical tint otherwise. */\n  avatar?: string;\n}\n\nexport interface AuditChange {\n  field: string;\n  before?: ReactNode;\n  after?: ReactNode;\n}\n\nexport interface AuditEntry {\n  id: string;\n  actor: AuditActor;\n  /** Past-tense verb shown between actor and target, e.g. \"updated\". */\n  action: string;\n  /** Bucket used by the action-type filter. */\n  type: AuditActionType;\n  /** What was acted on, e.g. \"billing-plan\" or \"API key ••4f2a\". */\n  target: string;\n  /** Date, epoch ms or ISO string. */\n  timestamp: Date | number | string;\n  severity?: AuditSeverity;\n  /** Free-form area, e.g. \"Billing\", \"Security\". */\n  category?: string;\n  /** Field-level diff shown when the entry is expanded. */\n  changes?: AuditChange[];\n}\n\nexport interface AuditTrailProps {\n  entries: AuditEntry[];\n  /** Export button handler, called with the currently filtered entries. Hidden when omitted. */\n  onExport?: (entries: AuditEntry[]) => void;\n  /** Heading. Default \"Audit log\". */\n  title?: ReactNode;\n  /** Locale for dates and relative times. Default \"en-US\" so server and client agree. */\n  locale?: string;\n  /** Height of the scrolling list. Default \"22rem\". */\n  listHeight?: string;\n  className?: string;\n}\n\nconst DAY = 86_400_000;\n\nconst TYPE_OPTIONS: { value: \"all\" | AuditActionType; label: string }[] = [\n  { value: \"all\", label: \"All\" },\n  { value: \"create\", label: \"Create\" },\n  { value: \"update\", label: \"Update\" },\n  { value: \"delete\", label: \"Delete\" },\n  { value: \"access\", label: \"Access\" },\n];\n\n// Theme tokens mixed toward the foreground so the text keeps contrast on both\n// light and dark cards (same recipe as Alert).\nconst SEVERITY_CLASS: Record<AuditSeverity, string> = {\n  info: \"bg-foreground/5 text-muted-foreground\",\n  notice:\n    \"bg-[color-mix(in_oklab,var(--chart-1)_12%,transparent)] text-[color-mix(in_oklab,var(--chart-1)_70%,var(--foreground))]\",\n  warning:\n    \"bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] text-[color-mix(in_oklab,var(--warning)_50%,var(--foreground))]\",\n  critical:\n    \"bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]\",\n};\n\nfunction toMs(t: AuditEntry[\"timestamp\"]) {\n  return t instanceof Date ? t.getTime() : typeof t === \"number\" ? t : Date.parse(t);\n}\n\nfunction initials(name: string) {\n  const parts = name.trim().split(/\\s+/);\n  return ((parts[0]?.[0] ?? \"\") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? \"\") : \"\")).toUpperCase();\n}\n\nfunction textOf(node: ReactNode): string {\n  return typeof node === \"string\" || typeof node === \"number\" ? String(node) : \"\";\n}\n\n/**\n * Chronological activity log grouped by day. Filter by actor, action type and\n * free text; entries with a field diff expand (height morph) to show the old\n * value struck through beside the new one. Entries added after mount slide in\n * at the top. \"Now\" — and with it day grouping and relative times — is read\n * after mount, so server and client render the same skeleton first.\n */\nexport function AuditTrail({\n  entries,\n  onExport,\n  title = \"Audit log\",\n  locale = \"en-US\",\n  listHeight = \"22rem\",\n  className,\n}: AuditTrailProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const [now, setNow] = useState<number | null>(null);\n  useEffect(() => {\n    setNow(Date.now());\n    const t = window.setInterval(() => setNow(Date.now()), 30_000);\n    return () => window.clearInterval(t);\n  }, []);\n\n  const [actors, setActors] = useState<string[]>([]);\n  const [type, setType] = useState<\"all\" | AuditActionType>(\"all\");\n  const [query, setQuery] = useState(\"\");\n  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());\n\n  // Entries present at first render don't animate in; anything added later\n  // slides in from the top. A row reads `fresh` only when it mounts, so ids\n  // are marked known right after the commit that first rendered them.\n  const known = useRef<Set<string> | null>(null);\n  if (known.current === null) known.current = new Set(entries.map((e) => e.id));\n  const isNew = (id: string) => !known.current?.has(id);\n  useEffect(() => {\n    if (now === null) return;\n    for (const e of entries) known.current?.add(e.id);\n  }, [entries, now]);\n\n  const actorOptions = useMemo(() => {\n    const seen = new Map<string, AuditActor>();\n    for (const e of entries) if (!seen.has(e.actor.id)) seen.set(e.actor.id, e.actor);\n    return [...seen.values()]\n      .sort((a, b) => a.name.localeCompare(b.name))\n      .map((a) => ({ value: a.id, label: a.name }));\n  }, [entries]);\n  const actorIndex = useMemo(\n    () => new Map(actorOptions.map((o, i) => [o.value, i])),\n    [actorOptions],\n  );\n\n  const filtered = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    return entries\n      .filter((e) => (actors.length ? actors.includes(e.actor.id) : true))\n      .filter((e) => (type === \"all\" ? true : e.type === type))\n      .filter((e) => {\n        if (!q) return true;\n        const hay = [\n          e.actor.name,\n          e.action,\n          e.target,\n          e.category ?? \"\",\n          ...(e.changes ?? []).flatMap((c) => [c.field, textOf(c.before), textOf(c.after)]),\n        ]\n          .join(\" \")\n          .toLowerCase();\n        return hay.includes(q);\n      })\n      .sort((a, b) => toMs(b.timestamp) - toMs(a.timestamp));\n  }, [entries, actors, type, query]);\n\n  const dayFmt = useMemo(\n    () => new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\" }),\n    [locale],\n  );\n  const fullFmt = useMemo(\n    () => new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeStyle: \"short\" }),\n    [locale],\n  );\n  const rtf = useMemo(\n    () => new Intl.RelativeTimeFormat(locale, { numeric: \"auto\", style: \"short\" }),\n    [locale],\n  );\n\n  const groups = useMemo(() => {\n    if (now === null) return [];\n    const startOfToday = new Date(now);\n    startOfToday.setHours(0, 0, 0, 0);\n    const today = startOfToday.getTime();\n    const out: { key: string; label: string; items: AuditEntry[] }[] = [];\n    for (const e of filtered) {\n      const d = new Date(toMs(e.timestamp));\n      d.setHours(0, 0, 0, 0);\n      const start = d.getTime();\n      const key = String(start);\n      let group = out.at(-1);\n      if (!group || group.key !== key) {\n        const daysAgo = Math.round((today - start) / DAY);\n        const label =\n          daysAgo === 0 ? \"Today\" : daysAgo === 1 ? \"Yesterday\" : dayFmt.format(start);\n        group = { key, label, items: [] };\n        out.push(group);\n      }\n      group.items.push(e);\n    }\n    return out;\n  }, [filtered, now, dayFmt]);\n\n  const relative = (t: AuditEntry[\"timestamp\"]) => {\n    if (now === null) return \"\";\n    const diff = Math.max(0, now - toMs(t));\n    const s = Math.round(diff / 1000);\n    if (s < 45) return rtf.format(0, \"second\");\n    const m = Math.round(s / 60);\n    if (m < 60) return rtf.format(-m, \"minute\");\n    const h = Math.round(m / 60);\n    if (h < 24) return rtf.format(-h, \"hour\");\n    return rtf.format(-Math.round(diff / DAY), \"day\");\n  };\n\n  const toggle = (id: string) =>\n    setExpanded((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n\n  const filtersActive = actors.length > 0 || type !== \"all\" || query.trim() !== \"\";\n  const clearFilters = () => {\n    setActors([]);\n    setType(\"all\");\n    setQuery(\"\");\n  };\n\n  return (\n    <section\n      aria-labelledby={`${baseId}-title`}\n      className={cn(\n        \"flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground\",\n        className,\n      )}\n    >\n      <header className=\"flex items-center justify-between gap-3\">\n        <div className=\"flex min-w-0 items-baseline gap-2\">\n          <h3 id={`${baseId}-title`} className=\"truncate text-sm font-semibold\">\n            {title}\n          </h3>\n          <span className=\"text-xs tabular-nums text-muted-foreground\" aria-live=\"polite\">\n            {filtered.length} {filtered.length === 1 ? \"event\" : \"events\"}\n          </span>\n        </div>\n        {onExport ? (\n          <Button variant=\"outline\" size=\"sm\" onClick={() => onExport(filtered)}>\n            <Download className=\"size-3.5\" aria-hidden />\n            Export\n          </Button>\n        ) : null}\n      </header>\n\n      <div className=\"grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]\">\n        <label className=\"relative flex min-h-11 items-center\">\n          <span className=\"sr-only\">Search events</span>\n          <Search className=\"pointer-events-none absolute left-3 size-4 text-muted-foreground\" aria-hidden />\n          <input\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search actor, target, field…\"\n            className=\"h-11 w-full rounded-2xl border border-border bg-background pr-3 pl-9 text-sm outline-none transition-colors placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:hidden\"\n          />\n        </label>\n        <MultiSelect\n          options={actorOptions}\n          value={actors}\n          onValueChange={setActors}\n          placeholder={actors.length ? \"\" : \"All actors\"}\n          aria-label=\"Filter by actor\"\n          maxVisibleChips={1}\n        />\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2\">\n        <SegmentedControl\n          size=\"sm\"\n          options={TYPE_OPTIONS}\n          value={type}\n          onValueChange={(v) => setType(v as \"all\" | AuditActionType)}\n          aria-label=\"Filter by action type\"\n        />\n        <AnimatePresence initial={false}>\n          {filtersActive ? (\n            <motion.button\n              type=\"button\"\n              key=\"clear\"\n              onClick={clearFilters}\n              initial={{ opacity: 0, x: reduce ? 0 : 6 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={{ duration: 0.2, ease: EASE_OUT }}\n              className=\"inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground outline-none transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <X className=\"size-3\" aria-hidden />\n              Clear\n            </motion.button>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      <div\n        className=\"-mx-1 overflow-y-auto px-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n        style={{ height: listHeight }}\n      >\n        {now === null ? (\n          <div className=\"flex flex-col gap-4 pt-1\" aria-hidden>\n            {[0, 1, 2, 3].map((i) => (\n              <div key={i} className=\"flex items-start gap-3\">\n                <Skeleton shape=\"circle\" width={28} />\n                <div className=\"flex flex-1 flex-col gap-2 pt-1\">\n                  <Skeleton shape=\"text\" className=\"w-3/4\" />\n                  <Skeleton shape=\"text\" className=\"h-3 w-1/3\" />\n                </div>\n              </div>\n            ))}\n          </div>\n        ) : groups.length === 0 ? (\n          <EmptyState\n            size=\"sm\"\n            title=\"No matching events\"\n            description=\"Try another actor, action type or search term.\"\n            action={\n              filtersActive ? (\n                <Button variant=\"outline\" size=\"sm\" onClick={clearFilters}>\n                  Clear filters\n                </Button>\n              ) : undefined\n            }\n          />\n        ) : (\n          <div className=\"flex flex-col gap-3\">\n            {groups.map((group) => (\n              <section key={group.key} aria-label={group.label}>\n                <h4 className=\"sticky top-0 z-10 bg-card/95 py-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase backdrop-blur-sm\">\n                  {group.label}\n                </h4>\n                <ol className=\"relative\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {group.items.map((entry, i) => (\n                      <AuditRow\n                        key={entry.id}\n                        entry={entry}\n                        last={i === group.items.length - 1}\n                        fresh={isNew(entry.id)}\n                        expanded={expanded.has(entry.id)}\n                        onToggle={() => toggle(entry.id)}\n                        tint={seriesColor({}, actorIndex.get(entry.actor.id) ?? 0)}\n                        relative={relative(entry.timestamp)}\n                        absolute={fullFmt.format(toMs(entry.timestamp))}\n                        iso={new Date(toMs(entry.timestamp)).toISOString()}\n                        reduce={reduce}\n                        panelId={`${baseId}-${entry.id}`}\n                      />\n                    ))}\n                  </AnimatePresence>\n                </ol>\n              </section>\n            ))}\n          </div>\n        )}\n      </div>\n    </section>\n  );\n}\n\nfunction AuditRow({\n  entry,\n  last,\n  fresh,\n  expanded,\n  onToggle,\n  tint,\n  relative,\n  absolute,\n  iso,\n  reduce,\n  panelId,\n  ref,\n}: {\n  /** Forwarded so AnimatePresence's popLayout can measure the exiting row. */\n  ref?: Ref<HTMLLIElement>;\n  entry: AuditEntry;\n  last: boolean;\n  fresh: boolean;\n  expanded: boolean;\n  onToggle: () => void;\n  tint: string;\n  relative: string;\n  absolute: string;\n  iso: string;\n  reduce: boolean;\n  panelId: string;\n}) {\n  const hasDiff = Boolean(entry.changes?.length);\n  const severity = entry.severity ?? \"info\";\n\n  const summary = (\n    <>\n      <span className=\"font-medium text-foreground\">{entry.actor.name}</span>{\" \"}\n      <span className=\"text-muted-foreground\">{entry.action}</span>{\" \"}\n      <span className=\"font-mono text-[12px] text-foreground\">{entry.target}</span>\n    </>\n  );\n\n  const meta = (\n    <span className=\"mt-1 flex flex-wrap items-center gap-1.5\">\n      <span\n        className={cn(\n          \"inline-flex h-5 items-center rounded-full px-1.5 text-[10px] font-semibold capitalize\",\n          SEVERITY_CLASS[severity],\n        )}\n      >\n        {severity}\n      </span>\n      {entry.category ? (\n        <span className=\"inline-flex h-5 items-center rounded-full border border-border px-1.5 text-[10px] font-medium text-muted-foreground\">\n          {entry.category}\n        </span>\n      ) : null}\n      {hasDiff ? (\n        <span className=\"text-[11px] text-muted-foreground\">\n          {entry.changes?.length} {entry.changes?.length === 1 ? \"field\" : \"fields\"}\n        </span>\n      ) : null}\n    </span>\n  );\n\n  return (\n    <motion.li\n      ref={ref}\n      layout={reduce ? false : \"position\"}\n      initial={fresh ? { opacity: 0, y: reduce ? 0 : -14 } : false}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, transition: { duration: 0.12 } }}\n      transition={{ default: { duration: 0.32, ease: EASE_OUT }, layout: SPRING_LAYOUT }}\n      className=\"relative flex gap-3 pb-3\"\n    >\n      {/* Rail connecting avatars within a day. */}\n      {!last ? (\n        <span aria-hidden className=\"absolute top-8 bottom-0 left-3.5 w-px bg-border-strong\" />\n      ) : null}\n      <span\n        aria-hidden\n        className=\"relative grid size-7 shrink-0 place-items-center overflow-hidden rounded-full text-[10px] font-semibold text-foreground ring-2 ring-card\"\n        style={{ background: `color-mix(in oklab, ${tint} 28%, var(--card))` }}\n      >\n        {entry.actor.avatar ? (\n          // biome-ignore lint/performance/noImgElement: library component; consumers pass any URL.\n          <img src={entry.actor.avatar} alt=\"\" className=\"size-full object-cover\" />\n        ) : (\n          initials(entry.actor.name)\n        )}\n      </span>\n\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-start gap-2\">\n          {hasDiff ? (\n            <button\n              type=\"button\"\n              aria-expanded={expanded}\n              aria-controls={panelId}\n              onClick={onToggle}\n              className=\"group -mx-1.5 -my-0.5 flex min-w-0 flex-1 items-start gap-1.5 rounded-lg px-1.5 py-0.5 text-left text-sm outline-none transition-colors hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <span className=\"min-w-0 flex-1\">\n                <span className=\"block\">{summary}</span>\n                {meta}\n              </span>\n              <motion.span\n                aria-hidden\n                animate={{ rotate: expanded ? 180 : 0 }}\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className=\"mt-0.5 inline-flex text-muted-foreground group-hover:text-foreground\"\n              >\n                <ChevronDown className=\"size-4\" />\n              </motion.span>\n            </button>\n          ) : (\n            <div className=\"min-w-0 flex-1 text-sm\">\n              <span className=\"block\">{summary}</span>\n              {meta}\n            </div>\n          )}\n          <time\n            dateTime={iso}\n            title={absolute}\n            className=\"shrink-0 pt-0.5 text-[11px] tabular-nums text-muted-foreground\"\n          >\n            {relative}\n          </time>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {hasDiff && expanded ? (\n            // Height is animated on purpose: the disclosure's job is to open\n            // space in the list, and siblings glide with it.\n            <motion.div\n              key=\"diff\"\n              id={panelId}\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0, transition: { duration: 0.16, ease: EASE_OUT } }}\n              transition={reduce ? { duration: 0 } : { duration: 0.26, ease: EASE_OUT }}\n              className=\"overflow-hidden\"\n            >\n              <dl className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 rounded-xl border border-border bg-background/60 p-2.5 text-xs\">\n                {entry.changes?.map((c) => (\n                  <div key={c.field} className=\"contents\">\n                    <dt className=\"pt-0.5 font-mono text-muted-foreground\">{c.field}</dt>\n                    <dd className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n                      {c.before !== undefined && c.before !== null ? (\n                        <del className=\"rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-1.5 py-0.5 font-mono text-[color-mix(in_oklab,var(--destructive)_70%,var(--foreground))] decoration-1\">\n                          <span className=\"sr-only\">was </span>\n                          {c.before}\n                        </del>\n                      ) : null}\n                      {c.before != null && c.after != null ? (\n                        <span aria-hidden className=\"text-muted-foreground\">\n                          →\n                        </span>\n                      ) : null}\n                      {c.after !== undefined && c.after !== null ? (\n                        <ins className=\"rounded-md bg-[color-mix(in_oklab,var(--success)_16%,transparent)] px-1.5 py-0.5 font-mono text-[color-mix(in_oklab,var(--success)_55%,var(--foreground))] no-underline\">\n                          <span className=\"sr-only\">now </span>\n                          {c.after}\n                        </ins>\n                      ) : null}\n                    </dd>\n                  </div>\n                ))}\n              </dl>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </motion.li>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\n"},{"path":"components/motion/chart/shared.tsx","type":"registry:component","target":"@components/motion/chart/shared.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\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/multi-select.tsx","type":"registry:component","target":"@components/motion/multi-select.tsx","content":"\"use client\";\n\nimport { ChevronsUpDown, Plus, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MultiSelectOption = {\n  value: string;\n  label: string;\n  /** Extra search terms. */\n  keywords?: string[];\n  disabled?: boolean;\n  icon?: ReactNode;\n};\n\nexport type MultiSelectGroup = {\n  label: string;\n  options: MultiSelectOption[];\n};\n\nexport interface MultiSelectProps {\n  /** Flat options, groups, or a mix. */\n  options: Array<MultiSelectOption | MultiSelectGroup>;\n  /** Controlled selected values. */\n  value?: string[];\n  /** Uncontrolled initial values. */\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Most selections allowed. Remaining options disable at the limit. */\n  max?: number;\n  /** Offer \"Create …\" when the search matches nothing exactly. */\n  creatable?: boolean;\n  /**\n   * Called with the typed label when creating. Return the new option's value\n   * (default: the label itself). Created options are kept in the list.\n   */\n  onCreate?: (label: string) => string | undefined;\n  /** Chips shown before collapsing the rest into \"+N\". Default 3. */\n  maxVisibleChips?: number;\n  /** Show the \"Select all\" / \"Clear\" bar. Default true. */\n  actions?: boolean;\n  label?: string;\n  placeholder?: string;\n  emptyMessage?: ReactNode;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\ntype FlatOption = MultiSelectOption & { group: number | null };\ntype Row =\n  | { kind: \"option\"; option: FlatOption; id: string }\n  | { kind: \"create\"; label: string; id: string };\n\nconst SHAKE = { x: [0, -5, 5, -3, 3, 0] };\n\nfunction isGroup(\n  entry: MultiSelectOption | MultiSelectGroup,\n): entry is MultiSelectGroup {\n  return \"options\" in entry;\n}\n\nfunction matches(option: MultiSelectOption, query: string) {\n  if (!query) return true;\n  const haystack = [option.label, option.value, ...(option.keywords ?? [])]\n    .join(\" \")\n    .toLowerCase();\n  return haystack.includes(query);\n}\n\n/**\n * Multi-value select whose field is the search input: picked values ride as\n * chips that spring in and glide aside, overflow collapses into \"+N\", and the\n * grouped listbox toggles options with draw-in checks.\n */\nexport function MultiSelect({\n  options,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  max,\n  creatable = false,\n  onCreate,\n  maxVisibleChips = 3,\n  actions = true,\n  label,\n  placeholder = \"Select…\",\n  emptyMessage = \"No options found.\",\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  className,\n}: MultiSelectProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const listId = `${uid}-list`;\n  const labelId = `${uid}-label`;\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState<string[]>(defaultValue ?? []);\n  const selected = controlled ? valueProp : internal;\n  const selectedSet = useMemo(() => new Set(selected), [selected]);\n\n  const openControlled = openProp !== undefined;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = (openControlled ? openProp : internalOpen) && !disabled;\n\n  const [query, setQuery] = useState(\"\");\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [created, setCreated] = useState<MultiSelectOption[]>([]);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  const { groups, flat } = useMemo(() => {\n    const groupLabels: string[] = [];\n    const all: FlatOption[] = [];\n    for (const entry of options) {\n      if (isGroup(entry)) {\n        groupLabels.push(entry.label);\n        for (const option of entry.options)\n          all.push({ ...option, group: groupLabels.length - 1 });\n      } else {\n        all.push({ ...entry, group: null });\n      }\n    }\n    const known = new Set(all.map((o) => o.value));\n    for (const option of created)\n      if (!known.has(option.value)) all.push({ ...option, group: null });\n    return { groups: groupLabels, flat: all };\n  }, [options, created]);\n\n  const byValue = useMemo(\n    () => new Map(flat.map((option) => [option.value, option])),\n    [flat],\n  );\n  const labelOf = (v: string) => byValue.get(v)?.label ?? v;\n\n  const needle = query.trim().toLowerCase();\n  const visible = useMemo(\n    () => flat.filter((option) => matches(option, needle)),\n    [flat, needle],\n  );\n  const atLimit = max !== undefined && selected.length >= max;\n  const isDisabled = (option: MultiSelectOption) =>\n    Boolean(option.disabled) || (atLimit && !selectedSet.has(option.value));\n\n  const canCreate =\n    creatable &&\n    needle.length > 0 &&\n    !flat.some((option) => option.label.toLowerCase() === needle);\n\n  // Navigable rows in visual order: ungrouped first, then each group, then create.\n  const rows = useMemo<Row[]>(() => {\n    const ordered: Row[] = [];\n    const push = (option: FlatOption) =>\n      ordered.push({\n        kind: \"option\",\n        option,\n        id: `${listId}-${encodeURIComponent(option.value)}`,\n      });\n    for (const option of visible) if (option.group === null) push(option);\n    groups.forEach((_, index) => {\n      for (const option of visible) if (option.group === index) push(option);\n    });\n    if (canCreate)\n      ordered.push({ kind: \"create\", label: query.trim(), id: `${listId}-create` });\n    return ordered;\n  }, [visible, groups, canCreate, query, listId]);\n\n  const navigable = rows.filter(\n    (row) => row.kind === \"create\" || !isDisabled(row.option),\n  );\n  const activeRow =\n    rows.find((row) => row.id === activeId && navigable.includes(row)) ?? null;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (disabled && next) return;\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) {\n        setQuery(\"\");\n        setActiveId(null);\n      }\n    },\n    [disabled, onOpenChange, openControlled],\n  );\n\n  const dismiss = useCallback(() => setOpen(false), [setOpen]);\n  useDismiss(open, dismiss, rootRef, { escape: false });\n\n  // Keep the active row in view while arrowing through a long list.\n  useEffect(() => {\n    if (!open || !activeRow) return;\n    const list = listRef.current;\n    const item = document.getElementById(activeRow.id);\n    if (!list || !item) return;\n    const itemRect = item.getBoundingClientRect();\n    const listRect = list.getBoundingClientRect();\n    if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top + 4;\n    else if (itemRect.bottom > listRect.bottom)\n      list.scrollTop += itemRect.bottom - listRect.bottom + 4;\n  }, [activeRow, open]);\n\n  const commit = (next: string[], message: string) => {\n    if (!controlled) setInternal(next);\n    onValueChange?.(next);\n    setAnnouncement(message);\n  };\n\n  const refuse = () => {\n    setAnnouncement(`Up to ${max} selections`);\n    if (fieldRef.current && !reduce)\n      animate(fieldRef.current, SHAKE, { duration: 0.35, ease: EASE_OUT });\n  };\n\n  const toggle = (option: MultiSelectOption) => {\n    if (option.disabled) return;\n    if (selectedSet.has(option.value)) {\n      commit(\n        selected.filter((v) => v !== option.value),\n        `Removed ${option.label}`,\n      );\n      return;\n    }\n    if (atLimit) {\n      refuse();\n      return;\n    }\n    commit([...selected, option.value], `Added ${option.label}`);\n  };\n\n  const create = (raw: string) => {\n    const text = raw.trim();\n    if (!text) return;\n    if (atLimit) {\n      refuse();\n      return;\n    }\n    const newValue = onCreate?.(text) ?? text;\n    if (!byValue.has(newValue))\n      setCreated((current) => [...current, { value: newValue, label: text }]);\n    if (!selectedSet.has(newValue))\n      commit([...selected, newValue], `Created and added ${text}`);\n    setQuery(\"\");\n  };\n\n  const activate = (row: Row) => {\n    if (row.kind === \"create\") create(row.label);\n    else toggle(row.option);\n  };\n\n  const removeValue = (v: string) => {\n    commit(\n      selected.filter((item) => item !== v),\n      `Removed ${labelOf(v)}`,\n    );\n  };\n\n  const selectAll = () => {\n    const additions: string[] = [];\n    const room = max === undefined ? Number.POSITIVE_INFINITY : max - selected.length;\n    for (const option of visible) {\n      if (additions.length >= room) break;\n      if (!option.disabled && !selectedSet.has(option.value))\n        additions.push(option.value);\n    }\n    if (!additions.length) {\n      if (room <= 0) refuse();\n      return;\n    }\n    commit([...selected, ...additions], `Added ${additions.length} options`);\n  };\n\n  const clearAll = () => {\n    if (!selected.length) return;\n    commit(\n      selected.filter((v) => byValue.get(v)?.disabled),\n      \"Cleared selection\",\n    );\n  };\n\n  const move = (direction: 1 | -1 | \"first\" | \"last\") => {\n    if (!navigable.length) {\n      setActiveId(null);\n      return;\n    }\n    if (direction === \"first\") return setActiveId(navigable[0].id);\n    if (direction === \"last\") return setActiveId(navigable[navigable.length - 1].id);\n    const index = activeRow ? navigable.indexOf(activeRow) : -1;\n    const next =\n      index < 0\n        ? direction === 1\n          ? 0\n          : navigable.length - 1\n        : (index + direction + navigable.length) % navigable.length;\n    setActiveId(navigable[next].id);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || event.nativeEvent.isComposing) return;\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault();\n        const direction = event.key === \"ArrowDown\" ? 1 : -1;\n        if (!open) {\n          setOpen(true);\n          setActiveId(null);\n          return;\n        }\n        move(direction);\n        return;\n      }\n      case \"Home\":\n      case \"End\":\n        if (!open || query) return;\n        event.preventDefault();\n        move(event.key === \"Home\" ? \"first\" : \"last\");\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        if (!open) setOpen(true);\n        else if (activeRow) activate(activeRow);\n        else if (canCreate) create(query);\n        return;\n      case \"Escape\":\n        if (open) {\n          event.preventDefault();\n          setOpen(false);\n        } else if (query) {\n          setQuery(\"\");\n        }\n        return;\n      case \"Backspace\":\n        if (query || !selected.length) return;\n        event.preventDefault();\n        removeValue(selected[selected.length - 1]);\n        return;\n      case \"Tab\":\n        if (open) setOpen(false);\n        return;\n    }\n  };\n\n  // Clicks on the field's empty space land in the input and toggle the list.\n  const onFieldMouseDown = (event: MouseEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const target = event.target as HTMLElement;\n    if (target.closest(\"button\")) return;\n    if (target !== inputRef.current) event.preventDefault();\n    inputRef.current?.focus();\n    setOpen(!open || target === inputRef.current);\n  };\n\n  const visibleChips = selected.slice(0, maxVisibleChips);\n  const hidden = selected.slice(maxVisibleChips);\n  const chipTransition = reduce ? { duration: 0.15 } : SPRING_SWAP;\n  const chipInitial = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 };\n  const chipExit = reduce\n    ? { opacity: 0, transition: { duration: 0.1 } }\n    : { opacity: 0, scale: 0.6, transition: { duration: 0.14, ease: EASE_OUT } };\n\n  const renderRow = (row: Row) => {\n    const isActive = activeRow?.id === row.id;\n    const highlight = isActive ? (\n      <motion.span\n        aria-hidden\n        layoutId={`${uid}-active`}\n        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n        className=\"absolute inset-0 -z-10 rounded-lg bg-muted\"\n      />\n    ) : null;\n\n    if (row.kind === \"create\") {\n      return (\n        <button\n          key={row.id}\n          id={row.id}\n          type=\"button\"\n          role=\"option\"\n          tabIndex={-1}\n          aria-selected={false}\n          onMouseDown={(event) => event.preventDefault()}\n          onPointerMove={() => setActiveId(row.id)}\n          onClick={() => create(row.label)}\n          className={cn(\n            \"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none\",\n            isActive ? \"text-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {highlight}\n          <span className=\"grid size-4 shrink-0 place-items-center\">\n            <Plus className=\"size-3.5\" aria-hidden />\n          </span>\n          <span className=\"min-w-0 flex-1 truncate\">\n            Create <span className=\"font-medium text-foreground\">“{row.label}”</span>\n          </span>\n        </button>\n      );\n    }\n\n    const { option } = row;\n    const checked = selectedSet.has(option.value);\n    const off = isDisabled(option);\n    return (\n      <button\n        key={row.id}\n        id={row.id}\n        type=\"button\"\n        role=\"option\"\n        tabIndex={-1}\n        aria-selected={checked}\n        aria-disabled={off || undefined}\n        data-active={isActive || undefined}\n        onMouseDown={(event) => event.preventDefault()}\n        onPointerMove={() => {\n          if (!off) setActiveId(row.id);\n        }}\n        onClick={() => {\n          if (off) {\n            if (atLimit && !option.disabled) refuse();\n            return;\n          }\n          setActiveId(row.id);\n          toggle(option);\n        }}\n        className={cn(\n          \"relative flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150\",\n          isActive || checked ? \"text-foreground\" : \"text-muted-foreground\",\n          off && \"cursor-not-allowed opacity-45\",\n        )}\n      >\n        {highlight}\n        <CheckBox checked={checked} reduce={Boolean(reduce)} />\n        {option.icon ? (\n          <span\n            aria-hidden\n            className=\"grid shrink-0 place-items-center [&_svg]:size-4\"\n          >\n            {option.icon}\n          </span>\n        ) : null}\n        <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n      </button>\n    );\n  };\n\n  const ungroupedRows = rows.filter(\n    (row) => row.kind === \"option\" && row.option.group === null,\n  );\n  const createRow = rows.find((row) => row.kind === \"create\");\n\n  return (\n    <div ref={rootRef} className={cn(\"relative flex w-full flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          id={labelId}\n          htmlFor={inputId}\n          className=\"px-1 text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only — the combobox input inside owns focus and keyboard. */}\n      <div\n        ref={fieldRef}\n        onMouseDown={onFieldMouseDown}\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"flex min-h-11 w-full cursor-text items-center gap-2 rounded-2xl border border-border bg-background py-1.5 pr-2 pl-2 transition-colors duration-200 hover:border-foreground/20\",\n          \"focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40\",\n          disabled && \"pointer-events-none opacity-60\",\n        )}\n      >\n        <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {visibleChips.map((v) => (\n              <motion.span\n                key={v}\n                layout={!reduce}\n                initial={chipInitial}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={chipExit}\n                transition={{ default: chipTransition, layout: SPRING_LAYOUT }}\n                className=\"inline-flex h-7 max-w-40 items-center gap-1 rounded-full bg-muted pr-1 pl-2.5 text-sm text-foreground\"\n              >\n                <span className=\"truncate\">{labelOf(v)}</span>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  disabled={disabled}\n                  aria-label={`Remove ${labelOf(v)}`}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onClick={() => removeValue(v)}\n                  className=\"grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground\"\n                >\n                  <X className=\"size-3\" aria-hidden />\n                </button>\n              </motion.span>\n            ))}\n            {hidden.length > 0 ? (\n              <motion.span\n                key=\"overflow\"\n                layout={!reduce}\n                initial={chipInitial}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={chipExit}\n                transition={{ default: chipTransition, layout: SPRING_LAYOUT }}\n                title={hidden.map(labelOf).join(\", \")}\n                className=\"inline-flex h-7 items-center rounded-full border border-border px-2.5 text-sm tabular-nums text-muted-foreground\"\n              >\n                <span aria-hidden>+{hidden.length}</span>\n                <span className=\"sr-only\">\n                  and {hidden.length} more: {hidden.map(labelOf).join(\", \")}\n                </span>\n              </motion.span>\n            ) : null}\n            <motion.input\n              key=\"input\"\n              layout={!reduce ? \"position\" : false}\n              transition={{ layout: SPRING_LAYOUT }}\n              ref={inputRef}\n              id={inputId}\n              type=\"text\"\n              role=\"combobox\"\n              aria-label={label ? undefined : (ariaLabel ?? \"Select options\")}\n              aria-autocomplete=\"list\"\n              aria-expanded={open}\n              aria-controls={listId}\n              aria-activedescendant={open && activeRow ? activeRow.id : undefined}\n              autoComplete=\"off\"\n              spellCheck={false}\n              disabled={disabled}\n              value={query}\n              placeholder={selected.length ? \"Search…\" : placeholder}\n              onChange={(event) => {\n                setQuery(event.target.value);\n                setActiveId(null);\n                if (!open) setOpen(true);\n              }}\n              onKeyDown={onKeyDown}\n              className=\"h-7 min-w-20 flex-1 bg-transparent px-1 text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed\"\n            />\n          </AnimatePresence>\n        </div>\n        <span aria-hidden className=\"shrink-0 self-center text-muted-foreground\">\n          <ChevronsUpDown className=\"size-4\" />\n        </span>\n      </div>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            key=\"panel\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    y: -4,\n                    scale: 0.98,\n                    transition: { duration: 0.12, ease: EASE_OUT },\n                  }\n            }\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            style={{ transformOrigin: \"top\" }}\n            className=\"absolute inset-x-0 top-full z-30 mt-1.5 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg\"\n          >\n            {actions ? (\n              <div className=\"flex items-center justify-between gap-2 border-b border-border px-3 py-1.5 text-xs\">\n                <span className=\"tabular-nums text-muted-foreground\">\n                  {selected.length}\n                  {max !== undefined ? `/${max}` : \"\"} selected\n                </span>\n                <div className=\"flex items-center gap-1\">\n                  <button\n                    type=\"button\"\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={selectAll}\n                    className=\"rounded-md px-2 py-1 font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted\"\n                  >\n                    Select all\n                  </button>\n                  <button\n                    type=\"button\"\n                    disabled={!selected.length}\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={clearAll}\n                    className=\"rounded-md px-2 py-1 text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40\"\n                  >\n                    Clear\n                  </button>\n                </div>\n              </div>\n            ) : null}\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-multiselectable\n              aria-labelledby={label ? labelId : undefined}\n              aria-label={label ? undefined : (ariaLabel ?? \"Options\")}\n              className=\"relative isolate max-h-60 overflow-y-auto overscroll-contain p-1.5 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n            >\n              {ungroupedRows.map(renderRow)}\n              {groups.map((groupLabel, index) => {\n                const groupRows = rows.filter(\n                  (row) => row.kind === \"option\" && row.option.group === index,\n                );\n                if (!groupRows.length) return null;\n                return (\n                  <fieldset\n                    // biome-ignore lint/suspicious/noArrayIndexKey: group order is the identity here.\n                    key={index}\n                    className=\"m-0 min-w-0 border-0 p-0 py-0.5\"\n                  >\n                    <legend className=\"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\">\n                      {groupLabel}\n                    </legend>\n                    {groupRows.map(renderRow)}\n                  </fieldset>\n                );\n              })}\n              {createRow ? renderRow(createRow) : null}\n              {rows.length === 0 ? (\n                <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n                  {emptyMessage}\n                </p>\n              ) : null}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction CheckBox({ checked, reduce }: { checked: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid size-4 shrink-0 place-items-center rounded-[5px] border transition-colors duration-150\",\n        checked\n          ? \"border-primary bg-primary text-primary-foreground\"\n          : \"border-muted-foreground/40 bg-transparent text-transparent\",\n      )}\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n        <motion.path\n          d=\"M5 12.5l4.5 4.5L19 7.5\"\n          stroke=\"currentColor\"\n          strokeWidth={3.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}\n        />\n      </svg>\n    </span>\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/skeleton.tsx","type":"registry:component","target":"@components/motion/skeleton.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SkeletonShape = \"rect\" | \"circle\" | \"text\";\n\nexport interface SkeletonProps {\n  shape?: SkeletonShape;\n  /** CSS width (number = px). Text defaults to 100%, circles to 40px. */\n  width?: number | string;\n  /** CSS height (number = px). Circles default to their width. */\n  height?: number | string;\n  /** Sweep a highlight across the block. Reduced motion swaps it for a pulse. */\n  shimmer?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst SHAPE_CLASS: Record<SkeletonShape, string> = {\n  rect: \"rounded-lg\",\n  circle: \"rounded-full shrink-0\",\n  text: \"h-3.5 rounded-md\",\n};\n\n// Reduced motion: no sweep; the block breathes in opacity only.\nconst PULSE = {\n  animate: { opacity: [1, 0.55, 1] },\n  transition: { duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\n/**\n * A placeholder block. The shimmer is a gradient strip translated across the\n * block (transform only), clipped by the block's own overflow.\n */\nexport function Skeleton({\n  shape = \"rect\",\n  width,\n  height,\n  shimmer = true,\n  className,\n  style,\n}: SkeletonProps) {\n  const reduce = useReducedMotion();\n  const resolvedWidth = width ?? (shape === \"circle\" ? 40 : undefined);\n  const resolvedHeight = height ?? (shape === \"circle\" ? resolvedWidth : undefined);\n\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative overflow-hidden bg-muted\",\n        SHAPE_CLASS[shape],\n        shape === \"text\" && \"w-full\",\n        className,\n      )}\n      style={{ width: resolvedWidth, height: resolvedHeight, ...style }}\n      animate={reduce && shimmer ? PULSE.animate : undefined}\n      transition={reduce && shimmer ? PULSE.transition : undefined}\n    >\n      {shimmer && !reduce ? (\n        <motion.span\n          className=\"absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]\"\n          initial={{ x: \"-100%\" }}\n          animate={{ x: \"100%\" }}\n          transition={{\n            duration: 1.5,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            repeatDelay: 0.3,\n          }}\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\nexport interface SkeletonTextProps {\n  /** Number of lines. */\n  lines?: number;\n  /** Width of the final line, so the block reads like a real paragraph. */\n  lastLineWidth?: number | string;\n  /** Gap between lines (Tailwind class). */\n  gapClassName?: string;\n  lineClassName?: string;\n  shimmer?: boolean;\n  className?: string;\n}\n\n/** A paragraph of text lines with a shorter, natural-looking last line. */\nexport function SkeletonText({\n  lines = 3,\n  lastLineWidth = \"62%\",\n  gapClassName = \"gap-2\",\n  lineClassName,\n  shimmer = true,\n  className,\n}: SkeletonTextProps) {\n  const count = Math.max(1, lines);\n  return (\n    <div aria-hidden=\"true\" className={cn(\"flex w-full flex-col\", gapClassName, className)}>\n      {Array.from({ length: count }, (_, i) => (\n        <Skeleton\n          // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional and never reorder\n          key={i}\n          shape=\"text\"\n          shimmer={shimmer}\n          className={lineClassName}\n          width={count > 1 && i === count - 1 ? lastLineWidth : \"100%\"}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport interface SkeletonSwapProps {\n  /** While true the skeleton shows; flip to false to cross-fade to children. */\n  loading: boolean;\n  /** Placeholder rendered while loading — mirror the real content's shape. */\n  skeleton: ReactNode;\n  /** Real content. May be absent while loading. */\n  children?: ReactNode;\n  /**\n   * Keep the last measured content height while reloading, so a reload never\n   * collapses the box to the skeleton's height. Default true.\n   */\n  keepHeight?: boolean;\n  /** Announced to screen readers while loading. */\n  loadingLabel?: string;\n  className?: string;\n}\n\n/**\n * Cross-fades from a skeleton to real content without layout shift: both\n * layers share one grid cell during the fade (the box takes the taller of the\n * two), and the last measured content height is held as a min-height while a\n * reload shows the skeleton again.\n */\nexport function SkeletonSwap({\n  loading,\n  skeleton,\n  children,\n  keepHeight = true,\n  loadingLabel = \"Loading\",\n  className,\n}: SkeletonSwapProps) {\n  const reduce = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [measured, setMeasured] = useState<number | null>(null);\n\n  useEffect(() => {\n    const node = contentRef.current;\n    if (loading || !node || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([entry]) => {\n      if (entry) setMeasured(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    ro.observe(node);\n    return () => ro.disconnect();\n  }, [loading]);\n\n  return (\n    <div\n      aria-busy={loading}\n      className={cn(\"relative grid\", className)}\n      style={keepHeight && loading && measured != null ? { minHeight: measured } : undefined}\n    >\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {loading ? loadingLabel : \"\"}\n      </span>\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.div\n            key=\"skeleton\"\n            className=\"[grid-area:1/1]\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n          >\n            {skeleton}\n          </motion.div>\n        ) : (\n          <motion.div\n            key=\"content\"\n            ref={contentRef}\n            className=\"[grid-area:1/1]\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}\n            transition={{ duration: 0.28, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          // The ripple layer clips itself, so on a touch screen the button can\n          // stop clipping and let its enlarged hit area overhang.\n          ripple && \"overflow-hidden pointer-coarse:overflow-visible\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"registry:component","target":"@components/motion/button/magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"registry:component","target":"@components/motion/button/stateful.tsx","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}