{"slug":"availability-scheduler","name":"Availability Scheduler","description":"Weekly availability editor where each day springs between available and unavailable, time ranges add and remove with blur-slide motion, times pick from a scrollable dropdown, and a copy menu clones hours to other days.","category":"blocks","source_url":"https://beui.dev/r/availability-scheduler/raw","detail_url":"https://beui.dev/r/availability-scheduler","raw_url":"https://beui.dev/r/availability-scheduler/raw","page_url":"https://beui.dev/components/blocks/availability-scheduler","markdown_url":"https://beui.dev/components/blocks/availability-scheduler.md","published_at":"2026-07-10","updated_at":"2026-09-26","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["./copy-menu","./day-row","./icon-button","./time-select","./tooltip/positioner","./tooltip/use-position","./types","./use-position","@/components/motion/availability-scheduler","@/components/motion/checkbox","@/components/motion/popover-morph","@/components/motion/popover-position","@/components/motion/select","@/components/motion/switch","@/components/motion/tooltip","@/components/motion/tooltip-surface","@/lib/ease","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-tap-gesture","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/availability-scheduler/index.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/availability-scheduler\n\nimport { LayoutGroup, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { DayRow } from \"./day-row\";\nimport {\n  buildOptions,\n  type DayAvailability,\n  type DayKey,\n  defaultWeek,\n  panelKey,\n  WEEKDAYS,\n  type WeekAvailability,\n} from \"./types\";\n\nexport type {\n  DayAvailability,\n  DayKey,\n  TimeRange,\n  WeekAvailability,\n} from \"./types\";\nexport { defaultWeek } from \"./types\";\n\nexport interface AvailabilitySchedulerProps {\n  value?: WeekAvailability;\n  defaultValue?: WeekAvailability;\n  onChange?: (value: WeekAvailability) => void;\n  /** Minutes between selectable times. Default 30. */\n  step?: number;\n  className?: string;\n}\n\nexport function AvailabilityScheduler({\n  value,\n  defaultValue,\n  onChange,\n  step = 30,\n  className,\n}: AvailabilitySchedulerProps) {\n  const reduce = useReducedMotion() ?? false;\n  const groupId = useId();\n  const options = useMemo(() => buildOptions(step), [step]);\n  const idRef = useRef(0);\n\n  const [internal, setInternal] = useState<WeekAvailability>(\n    () => defaultValue ?? defaultWeek(),\n  );\n  // The row that last opened a dropdown paints above the rest — see DayRow.\n  const [openDay, setOpenDay] = useState<DayKey | null>(null);\n  // Exactly one time panel is open at a time, and the scheduler is the one that\n  // knows which. A panel is absolutely positioned inside its own field, so two\n  // open at once paint over each other's options — and nothing else can close\n  // the first: a Select only dismisses on an outside *pointerdown*, which\n  // keyboard and assistive-technology activation never fires.\n  const [openPanel, setOpenPanel] = useState<string | null>(null);\n  const controlled = value !== undefined;\n  const week = controlled ? value : internal;\n\n  // Which panels the week currently puts on screen. A field that leaves — its\n  // day switched off, its range removed — never reports its panel closed: the\n  // only thing that dismisses a controlled Select is an outside pointerdown,\n  // and a field on its way out is no longer there to hear one. Keeping its id\n  // would reopen the panel the moment the same range came back.\n  const livePanels = useMemo(() => {\n    const ids = new Set<string>();\n    for (const { key } of WEEKDAYS) {\n      if (!week[key].enabled) continue;\n      for (const range of week[key].ranges) {\n        ids.add(panelKey(key, range.id, \"start\"));\n        ids.add(panelKey(key, range.id, \"end\"));\n      }\n    }\n    return ids;\n  }, [week]);\n\n  useEffect(() => {\n    if (openPanel !== null && !livePanels.has(openPanel)) setOpenPanel(null);\n  }, [livePanels, openPanel]);\n\n  const commit = useCallback(\n    (next: WeekAvailability) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n\n  const setDay = useCallback(\n    (day: DayKey, next: DayAvailability) => {\n      commit({ ...week, [day]: next });\n    },\n    [commit, week],\n  );\n\n  const panelOpenChange = useCallback(\n    (day: DayKey, id: string, open: boolean) => {\n      setOpenPanel((current) =>\n        open ? id : current === id ? null : current,\n      );\n      // Elevation stays on the row that opened last so the panel's collapse\n      // animation finishes above its neighbours.\n      if (open) setOpenDay(day);\n    },\n    [],\n  );\n\n  const copyDay = useCallback(\n    (from: DayKey, targets: DayKey[]) => {\n      const source = week[from];\n      const next = { ...week };\n      for (const t of targets) {\n        next[t] = {\n          enabled: source.enabled,\n          ranges: source.ranges.map((r) => ({\n            ...r,\n            id: `${t}-c${idRef.current++}`,\n          })),\n        };\n      }\n      commit(next);\n    },\n    [commit, week],\n  );\n\n  return (\n    <LayoutGroup id={groupId}>\n      <div className={cn(\"w-full max-w-xl divide-y divide-border\", className)}>\n        {WEEKDAYS.map(({ key, label }) => (\n          <DayRow\n            key={key}\n            day={key}\n            label={label}\n            state={week[key]}\n            options={options}\n            reduce={reduce}\n            elevated={openDay === key}\n            openPanel={openPanel}\n            onChange={(next) => setDay(key, next)}\n            onCopy={(targets) => copyDay(key, targets)}\n            onPanelOpenChange={(id, open) => panelOpenChange(key, id, open)}\n          />\n        ))}\n      </div>\n    </LayoutGroup>\n  );\n}\n"},{"path":"components/motion/availability-scheduler/day-row.tsx","type":"util","content":"\"use client\";\n\nimport { Plus, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useRef, useState } from \"react\";\nimport { Switch } from \"@/components/motion/switch\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { CopyMenu } from \"./copy-menu\";\nimport { IconButton } from \"./icon-button\";\nimport { TimeSelect } from \"./time-select\";\nimport {\n  clampRange,\n  type DayAvailability,\n  type DayKey,\n  endOptions,\n  panelKey,\n  startOptions,\n  type TimeOption,\n  type TimeRange,\n  toMinutes,\n  toValue,\n} from \"./types\";\n\nexport function DayRow({\n  day,\n  label,\n  state,\n  options,\n  reduce,\n  elevated,\n  openPanel,\n  onChange,\n  onCopy,\n  onPanelOpenChange,\n}: {\n  day: DayKey;\n  label: string;\n  state: DayAvailability;\n  options: TimeOption[];\n  reduce: boolean;\n  // True while this row holds the dropdown that opened last, which paints it\n  // above every other row. A time panel opens downward when there is room and\n  // upward when there isn't, so it has to clear the rows on either side of it\n  // — no fixed paint order can satisfy both directions. The flag stays on\n  // after the panel closes so the collapse animation stays on top too.\n  elevated: boolean;\n  /** Id of the one time panel the scheduler is holding open, if any. */\n  openPanel: string | null;\n  onChange: (next: DayAvailability) => void;\n  onCopy: (targets: DayKey[]) => void;\n  onPanelOpenChange: (panelId: string, open: boolean) => void;\n}) {\n  const idRef = useRef(0);\n  const nextId = () => `${day}-n${idRef.current++}`;\n  // Same rule one level down: ranges stack against each other inside the row.\n  const [openRangeId, setOpenRangeId] = useState<string | null>(null);\n\n  const panelId = (rangeId: string, edge: \"start\" | \"end\") =>\n    panelKey(day, rangeId, edge);\n\n  const onRangePanelOpenChange = (\n    rangeId: string,\n    id: string,\n    open: boolean,\n  ) => {\n    if (open) setOpenRangeId(rangeId);\n    onPanelOpenChange(id, open);\n  };\n\n  const setEnabled = (enabled: boolean) => {\n    if (enabled && state.ranges.length === 0) {\n      onChange({\n        enabled,\n        ranges: [{ id: nextId(), start: \"09:00\", end: \"17:00\" }],\n      });\n    } else {\n      onChange({ ...state, enabled });\n    }\n  };\n\n  const updateRange = (id: string, patch: Partial<TimeRange>) => {\n    const changed: \"start\" | \"end\" = patch.start !== undefined ? \"start\" : \"end\";\n    onChange({\n      ...state,\n      ranges: state.ranges.map((r) => {\n        if (r.id !== id) return r;\n        const next = { ...r, ...patch };\n        return { ...next, ...clampRange(next.start, next.end, options, changed) };\n      }),\n    });\n  };\n\n  const addRange = () => {\n    const last = state.ranges[state.ranges.length - 1];\n    const start = last ? Math.min(toMinutes(last.end) + 60, 24 * 60 - 60) : 540;\n    onChange({\n      enabled: true,\n      ranges: [\n        ...state.ranges,\n        { id: nextId(), start: toValue(start), end: toValue(start + 60) },\n      ],\n    });\n  };\n\n  const removeRange = (id: string) => {\n    const ranges = state.ranges.filter((r) => r.id !== id);\n    // Removing the last slot marks the day unavailable.\n    onChange({ enabled: ranges.length > 0, ranges });\n  };\n\n  const actions = (\n    <>\n      <Tooltip content=\"Add time\">\n        <IconButton\n          label={`Add time range to ${label}`}\n          reduce={reduce}\n          onClick={addRange}\n        >\n          <Plus className=\"h-4 w-4\" />\n        </IconButton>\n      </Tooltip>\n      <CopyMenu fromLabel={label} reduce={reduce} onApply={onCopy} />\n    </>\n  );\n\n  return (\n    <motion.div\n      layout={reduce ? false : \"position\"}\n      transition={SPRING_LAYOUT}\n      style={{ zIndex: elevated ? 1 : undefined }}\n      className=\"relative flex flex-col gap-3 py-4 sm:flex-row sm:items-start sm:gap-4\"\n    >\n      {/* toggle + label; actions ride along on mobile */}\n      <div className=\"flex items-center justify-between sm:w-36 sm:shrink-0 sm:justify-start sm:pt-1\">\n        <div className=\"flex items-center gap-2.5\">\n          <Switch\n            checked={state.enabled}\n            onCheckedChange={setEnabled}\n            ariaLabel={`Toggle ${label} availability`}\n            className=\"scale-90\"\n          />\n          <span className=\"text-sm font-medium text-foreground\">{label}</span>\n        </div>\n        <div className=\"flex items-center gap-1 sm:hidden\">{actions}</div>\n      </div>\n\n      {/* ranges or unavailable */}\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {state.enabled ? (\n            state.ranges.map((r) => (\n              <motion.div\n                key={r.id}\n                layout={reduce ? false : \"position\"}\n                style={{ zIndex: openRangeId === r.id ? 1 : undefined }}\n                initial={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: -6, filter: \"blur(4px)\" }\n                }\n                animate={\n                  reduce\n                    ? { opacity: 1 }\n                    : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                }\n                exit={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n                }\n                transition={SPRING_LAYOUT}\n                className=\"relative flex items-center gap-2\"\n              >\n                <div className=\"min-w-0 flex-1 sm:max-w-[132px]\">\n                  <TimeSelect\n                    value={r.start}\n                    options={startOptions(options, r.end, r.start)}\n                    onChange={(v) => updateRange(r.id, { start: v })}\n                    open={openPanel === panelId(r.id, \"start\")}\n                    onOpenChange={(open) =>\n                      onRangePanelOpenChange(r.id, panelId(r.id, \"start\"), open)\n                    }\n                  />\n                </div>\n                <span className=\"text-muted-foreground\">–</span>\n                <div className=\"min-w-0 flex-1 sm:max-w-[132px]\">\n                  <TimeSelect\n                    value={r.end}\n                    options={endOptions(options, r.start, r.end)}\n                    onChange={(v) => updateRange(r.id, { end: v })}\n                    open={openPanel === panelId(r.id, \"end\")}\n                    onOpenChange={(open) =>\n                      onRangePanelOpenChange(r.id, panelId(r.id, \"end\"), open)\n                    }\n                  />\n                </div>\n                <Tooltip content=\"Remove\">\n                  <IconButton\n                    label=\"Remove time range\"\n                    reduce={reduce}\n                    onClick={() => removeRange(r.id)}\n                  >\n                    <X className=\"h-4 w-4\" />\n                  </IconButton>\n                </Tooltip>\n              </motion.div>\n            ))\n          ) : (\n            <motion.span\n              key=\"unavailable\"\n              layout={reduce ? false : \"position\"}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              transition={SPRING_LAYOUT}\n              className=\"py-1 text-sm text-muted-foreground sm:py-2\"\n            >\n              Unavailable\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* actions (desktop) */}\n      <div className=\"hidden shrink-0 items-center gap-1 pt-0.5 sm:flex\">\n        {actions}\n      </div>\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/availability-scheduler/types.ts","type":"util","content":"export type DayKey = \"mon\" | \"tue\" | \"wed\" | \"thu\" | \"fri\" | \"sat\" | \"sun\";\n\nexport type TimeRange = { id: string; start: string; end: string };\nexport type DayAvailability = { enabled: boolean; ranges: TimeRange[] };\nexport type WeekAvailability = Record<DayKey, DayAvailability>;\nexport type TimeOption = { value: string; label: string };\n\nexport const WEEKDAYS: { key: DayKey; label: string }[] = [\n  { key: \"mon\", label: \"Monday\" },\n  { key: \"tue\", label: \"Tuesday\" },\n  { key: \"wed\", label: \"Wednesday\" },\n  { key: \"thu\", label: \"Thursday\" },\n  { key: \"fri\", label: \"Friday\" },\n  { key: \"sat\", label: \"Saturday\" },\n  { key: \"sun\", label: \"Sunday\" },\n];\n\n// ─── panels ──────────────────────────────────────────────────────────────────\n\n/**\n * Names one time field week-wide, because the scheduler holds a single open\n * panel for the whole week. Range ids belong to the value and are only unique\n * within a day, so the day is part of the name — and the scheduler builds the\n * same keys to ask whether the panel it is holding is still on screen.\n */\nexport const panelKey = (day: DayKey, rangeId: string, edge: \"start\" | \"end\") =>\n  `${day}:${rangeId}:${edge}`;\n\n// ─── time helpers ────────────────────────────────────────────────────────────\n\nexport const toMinutes = (v: string) => {\n  const [h, m] = v.split(\":\").map(Number);\n  return h * 60 + m;\n};\n\nexport const toValue = (mins: number) => {\n  const clamped = Math.max(0, Math.min(24 * 60 - 1, mins));\n  const h = Math.floor(clamped / 60);\n  const m = clamped % 60;\n  return `${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}`;\n};\n\nexport const label12 = (v: string) => {\n  const [h, m] = v.split(\":\").map(Number);\n  const ap = h < 12 ? \"AM\" : \"PM\";\n  const h12 = h % 12 === 0 ? 12 : h % 12;\n  return `${h12}:${String(m).padStart(2, \"0\")} ${ap}`;\n};\n\nexport function buildOptions(step: number): TimeOption[] {\n  const out: TimeOption[] = [];\n  for (let m = 0; m < 24 * 60; m += step) {\n    const value = toValue(m);\n    out.push({ value, label: label12(value) });\n  }\n  return out;\n}\n\nfunction snapToOption(mins: number, slots: number[]) {\n  let best = slots[0];\n  for (const slot of slots) {\n    if (Math.abs(slot - mins) < Math.abs(best - mins)) best = slot;\n  }\n  return best;\n}\n\nfunction withCurrentOption(\n  filtered: TimeOption[],\n  all: TimeOption[],\n  current?: string,\n) {\n  if (!current || filtered.some((o) => o.value === current)) return filtered;\n  const extra =\n    all.find((o) => o.value === current) ??\n    ({ value: current, label: label12(current) } satisfies TimeOption);\n  return [...filtered, extra].sort(\n    (a, b) => toMinutes(a.value) - toMinutes(b.value),\n  );\n}\n\n/**\n * Same-day ranges only. A valid pair is left alone so an off-grid persisted\n * end (17:00 with `step={720}`) is not rewritten. When the invariant fails,\n * the just-selected endpoint stays and only the opposite side moves onto a\n * neighboring generated option.\n */\nexport function clampRange(\n  start: string,\n  end: string,\n  options: TimeOption[],\n  changed: \"start\" | \"end\" = \"start\",\n): { start: string; end: string } {\n  const slots = options.map((o) => toMinutes(o.value));\n  if (slots.length === 0) return { start, end };\n\n  const startM = toMinutes(start);\n  const endM = toMinutes(end);\n  if (endM > startM) return { start, end };\n\n  const keepOrSnap = (value: string) => {\n    const mins = toMinutes(value);\n    return slots.includes(mins) ? mins : snapToOption(mins, slots);\n  };\n\n  if (changed === \"end\") {\n    const e = keepOrSnap(end);\n    const earlier = [...slots].reverse().find((slot) => slot < e);\n    if (earlier === undefined && slots.length > 1) {\n      // Midnight cannot end a positive same-day range, so use the first pair.\n      return { start: toValue(slots[0]), end: toValue(slots[1]) };\n    }\n    return {\n      start: toValue(earlier ?? keepOrSnap(start)),\n      end: toValue(e),\n    };\n  }\n\n  const s = keepOrSnap(start);\n  const later = slots.find((slot) => slot > s);\n  if (later === undefined && slots.length > 1) {\n    // The last slot cannot start a positive range, so use the final pair.\n    return {\n      start: toValue(slots[slots.length - 2]),\n      end: toValue(slots[slots.length - 1]),\n    };\n  }\n  return {\n    start: toValue(s),\n    end: toValue(later ?? keepOrSnap(end)),\n  };\n}\n\nexport function startOptions(\n  options: TimeOption[],\n  end: string,\n  current?: string,\n) {\n  const filtered = options.filter(\n    (o) => toMinutes(o.value) < toMinutes(end),\n  );\n  // An invalid midnight end has no earlier option; expose midnight so choosing\n  // it can move the end forward through clampRange instead of trapping the row.\n  const recovery = filtered.length === 0 ? options.slice(0, 1) : filtered;\n  return withCurrentOption(\n    recovery,\n    options,\n    current,\n  );\n}\n\nexport function endOptions(\n  options: TimeOption[],\n  start: string,\n  current?: string,\n) {\n  const filtered = options.filter(\n    (o) => toMinutes(o.value) > toMinutes(start),\n  );\n  // An invalid last-slot start has no later option; expose the last slot so\n  // choosing it can move the start backward through clampRange.\n  const recovery = filtered.length === 0 ? options.slice(-1) : filtered;\n  return withCurrentOption(\n    recovery,\n    options,\n    current,\n  );\n}\n\n// Default: Mon–Fri 9–5, weekend off. Fixed ids so SSR and first client render\n// agree (new ranges get counter ids afterwards).\nexport function defaultWeek(): WeekAvailability {\n  const workday = (day: DayKey): DayAvailability => ({\n    enabled: true,\n    ranges: [{ id: `${day}-0`, start: \"09:00\", end: \"17:00\" }],\n  });\n  const off = (day: DayKey): DayAvailability => ({\n    enabled: false,\n    ranges: [{ id: `${day}-0`, start: \"09:00\", end: \"17:00\" }],\n  });\n  return {\n    mon: workday(\"mon\"),\n    tue: workday(\"tue\"),\n    wed: workday(\"wed\"),\n    thu: workday(\"thu\"),\n    fri: workday(\"fri\"),\n    sat: off(\"sat\"),\n    sun: off(\"sun\"),\n  };\n}\n"},{"path":"lib/utils.ts","type":"util","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/availability-scheduler/copy-menu.tsx","type":"util","content":"\"use client\";\n\nimport { Check, Copy } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n} from \"@/components/motion/popover-morph\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { IconButton } from \"./icon-button\";\nimport { type DayKey, WEEKDAYS } from \"./types\";\n\n// Copy this day's hours to other days: a morph popover with a day picker.\nexport function CopyMenu({\n  fromLabel,\n  reduce,\n  onApply,\n}: {\n  fromLabel: string;\n  reduce: boolean;\n  onApply: (targets: DayKey[]) => void;\n}) {\n  const [open, setOpen] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const [picked, setPicked] = useState<Set<DayKey>>(new Set());\n  const others = WEEKDAYS.filter((d) => d.label !== fromLabel);\n\n  const toggle = (k: DayKey) =>\n    setPicked((prev) => {\n      const next = new Set(prev);\n      if (next.has(k)) next.delete(k);\n      else next.add(k);\n      return next;\n    });\n\n  const apply = (targets: DayKey[]) => {\n    if (!targets.length) return;\n    onApply(targets);\n    setOpen(false);\n    setPicked(new Set());\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1200);\n  };\n\n  return (\n    <MorphPopover open={open} onOpenChange={setOpen}>\n      <Tooltip content=\"Copy times\">\n        <IconButton\n          label={`Copy ${fromLabel} hours to other days`}\n          reduce={reduce}\n          expanded={open}\n          onClick={() => setOpen(!open)}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {copied ? (\n              <motion.span\n                key=\"done\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                transition={SPRING_PRESS}\n                className=\"text-foreground\"\n              >\n                <Check className=\"h-4 w-4\" />\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"copy\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                transition={SPRING_PRESS}\n              >\n                <Copy className=\"h-4 w-4\" />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </IconButton>\n      </Tooltip>\n\n      <MorphPopoverContent align=\"end\" className=\"w-52 p-2\">\n        <p className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\">\n          Copy times to\n        </p>\n        <div className=\"flex flex-col\">\n          {others.map((d) => (\n            <Checkbox\n              key={d.key}\n              checked={picked.has(d.key)}\n              onCheckedChange={() => toggle(d.key)}\n              label={d.label}\n              className=\"w-full flex-row-reverse justify-between rounded-lg px-2 py-1.5 transition-colors hover:bg-muted [&_button]:size-4 [&_button]:rounded-[5px] [&_button]:border [&_button[data-state=unchecked]]:border-border-strong\"\n            />\n          ))}\n        </div>\n        <div className=\"mt-1 flex items-center gap-2 border-t border-border px-1 pt-2\">\n          <button\n            type=\"button\"\n            onClick={() => apply(others.map((d) => d.key))}\n            className=\"flex-1 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted\"\n          >\n            Every day\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => apply([...picked])}\n            disabled={picked.size === 0}\n            className=\"flex-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\"\n          >\n            Apply\n          </button>\n        </div>\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n"},{"path":"components/motion/availability-scheduler/icon-button.tsx","type":"util","content":"\"use client\";\n\nimport { motion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport function IconButton({\n  onClick,\n  label,\n  disabled,\n  expanded,\n  reduce,\n  children,\n  className,\n  // Rest props let a wrapping Tooltip inject its hover/focus handlers.\n  ...rest\n}: {\n  onClick: () => void;\n  label: string;\n  disabled?: boolean;\n  expanded?: boolean;\n  reduce: boolean;\n  children: ReactNode;\n  className?: string;\n  [key: string]: unknown;\n}) {\n  return (\n    <motion.button\n      {...rest}\n      type=\"button\"\n      aria-label={label}\n      aria-expanded={expanded}\n      onClick={onClick}\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.86 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40\",\n        className,\n      )}\n    >\n      {children}\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/availability-scheduler/time-select.tsx","type":"util","content":"\"use client\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/motion/select\";\nimport type { TimeOption } from \"./types\";\n\n// Time field: the library Select, with the option list capped so the panel\n// measures a small height and scrolls instead of unfolding all 48 options.\nexport function TimeSelect({\n  value,\n  onChange,\n  open,\n  onOpenChange,\n  options,\n}: {\n  value: string;\n  onChange: (v: string) => void;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  options: TimeOption[];\n}) {\n  return (\n    <Select\n      value={value}\n      onValueChange={onChange}\n      open={open}\n      onOpenChange={onOpenChange}\n      className=\"w-full\"\n    >\n      <SelectTrigger className=\"tabular-nums\">\n        <SelectValue className=\"whitespace-nowrap\" />\n      </SelectTrigger>\n      <SelectContent>\n        <div className=\"max-h-56 overflow-y-auto overscroll-contain\">\n          {options.map((o) => (\n            <SelectItem key={o.value} value={o.value} className=\"tabular-nums\">\n              {o.label}\n            </SelectItem>\n          ))}\n        </div>\n      </SelectContent>\n    </Select>\n  );\n}\n"},{"path":"components/motion/switch.tsx","type":"util","content":"\"use client\";\n\nimport { animate, motion, MotionConfig, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.\nconst THUMB_SPRING = { type: \"spring\", stiffness: 800, damping: 80, mass: 4 } as const;\n\nexport interface SwitchProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  label?: string;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function Switch({\n  checked,\n  onCheckedChange,\n  disabled,\n  label,\n  ariaLabel,\n  className,\n}: SwitchProps) {\n  const id = useId();\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const [isPressed, setIsPressed] = useState(false);\n  const [isPointer, setIsPointer] = useState(false);\n\n  // Disabled shake feedback when pressed.\n  useEffect(() => {\n    if (!thumbRef.current || reduce) return;\n    if (disabled && isPressed) {\n      animate(\n        thumbRef.current,\n        { x: [0, -2, 2, -1, 0] },\n        { delay: 0.2, duration: 0.6 },\n      );\n    }\n  }, [disabled, isPressed, reduce]);\n\n  const squish = !disabled && isPointer && isPressed && !reduce;\n\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>\n      <span className={cn(\"inline-flex items-center gap-3\", className)}>\n        <motion.button\n          id={id}\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={checked}\n          aria-label={ariaLabel}\n          disabled={disabled}\n          onClick={() => !disabled && onCheckedChange(!checked)}\n          onPointerDown={(e) => {\n            setIsPressed(true);\n            setIsPointer(e.type.startsWith(\"pointer\"));\n          }}\n          onPointerUp={() => setIsPressed(false)}\n          onPointerLeave={() => setIsPressed(false)}\n          initial={false}\n          data-state={checked ? \"checked\" : \"unchecked\"}\n          className={cn(\n            \"group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            \"disabled:cursor-not-allowed disabled:opacity-60\",\n            checked ? \"justify-end bg-primary\" : \"justify-start bg-muted-foreground/60\",\n          )}\n        >\n          <motion.div\n            ref={thumbRef}\n            layout\n            animate={{ scale: squish ? 0.9 : 1 }}\n            className=\"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md\"\n          >\n            {/* Stretch toward the destination while active. */}\n            <div\n              className={cn(\n                \"size-5\",\n                squish && (checked ? \"ml-1\" : \"mr-1\"),\n              )}\n            />\n          </motion.div>\n        </motion.button>\n        {label ? (\n          <label htmlFor={id} className=\"cursor-pointer text-sm text-foreground\">\n            {label}\n          </label>\n        ) : null}\n      </span>\n    </MotionConfig>\n  );\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport { TooltipPositioner } from \"./tooltip/positioner\";\nimport { useTooltipPointer } from \"./tooltip/use-position\";\nimport {\n  cloneElement,\n  isValidElement,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children?: ReactElement;\n  /** Existing trigger for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<HTMLElement | SVGElement | null>;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: { x: number; y: number };\n  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n  followCursor?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  id?: string;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  delay = 120,\n  className,\n  wrapperClassName,\n  anchorRef: externalAnchorRef,\n  anchorPoint,\n  followCursor = false,\n  open: controlledOpen,\n  onOpenChange,\n  id: providedId,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = controlledOpen ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n  const generatedId = useId();\n  const id = providedId ?? generatedId;\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = externalAnchorRef ?? wrapperRef;\n  const hover = useHoverGesture();\n  const floatingRef = useRef<HTMLSpanElement | null>(null);\n  const pointer = useTooltipPointer(anchorRef, followCursor);\n  const focused = useRef(false);\n\n  const show = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    if (open) return;\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    if (warm) {\n      setOpen(true);\n      return;\n    }\n    timer.current = setTimeout(() => {\n      setOpen(true);\n    }, delay);\n  }, [delay, setOpen, open]);\n\n  const hide = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  }, [open, setOpen]);\n\n  const leave = useCallback(() => {\n    if (focused.current) return;\n    if (timer.current) clearTimeout(timer.current);\n    // Bridge the small physical gap to a stationary, readable tooltip.\n    if (followCursor) hide();\n    else timer.current = setTimeout(hide, 100);\n  }, [followCursor, hide]);\n  const insideTooltip = useCallback(\n    (target: Element) => Boolean(floatingRef.current?.contains(target)),\n    [],\n  );\n\n  // A finger never hovers, and Safari does not focus a button on tap either, so\n  // the label is only reachable if the tap itself opens the tooltip. A click\n  // carries no pointerType, so the pointerdown that preceded it is what says\n  // whether this was a tap; keyboard activation arrives with no pointerdown at\n  // all, and focus has already shown the label there.\n  const tap = useTapGesture<boolean>();\n\n  const toggleOnTap = useCallback(() => {\n    const gesture = tap.take();\n    if (!gesture || gesture.pointerType === \"mouse\") return;\n    if (gesture.state) {\n      hide();\n      return;\n    }\n    if (timer.current) clearTimeout(timer.current);\n    setOpen(true);\n  }, [hide, tap, setOpen]);\n\n  // ...and closed again by the next tap that lands somewhere else. The label\n  // covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, hide, anchorRef, { ignore: insideTooltip });\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  if (!externalAnchorRef && !isValidElement(children)) return children;\n\n  // The label describes the trigger, so it has to name the trigger itself.\n  // Everything else the tooltip needs is read off the anchor below instead of\n  // cloned on: a handler written onto the child is the child's handler as far\n  // as that child can tell, and a component that owns its activation —\n  // hard-wiring onClick and spreading the rest of its props over it, as\n  // ThemeToggle does — then runs the tooltip's instead of its own. Composing\n  // with `props.onClick` cannot save it either, because a component element's\n  // props hold nothing the component does internally.\n  const trigger = isValidElement(children)\n    ? cloneElement(children as ReactElement<Record<string, unknown>>, {\n        \"aria-describedby\":\n          [(children.props as Record<string, unknown>)[\"aria-describedby\"], open ? id : undefined]\n            .filter(Boolean)\n            .join(\" \") || undefined,\n      })\n    : null;\n\n  return (\n    <>\n      {!externalAnchorRef ? (\n        // biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.\n        <span\n          ref={wrapperRef}\n          className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n          // Pointer events, not the mouse pair: a tap fires compatibility\n          // mouseenter/mouseleave that carry no pointerType, which raced the tap\n          // path into opening and closing the same label.\n          onPointerEnter={(event: PointerEvent) => {\n            if (hover.enter(event)) show();\n          }}\n          onPointerLeave={(event: PointerEvent) => {\n            if (hover.leave(event)) leave();\n          }}\n          onFocus={() => {\n            focused.current = true;\n            show();\n          }}\n          onBlur={() => {\n            focused.current = false;\n            hide();\n          }}\n          onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n          // A gesture the platform took away sends no click, and a key press\n          // starts an activation that never had a pointer behind it. Either way\n          // the record has to go, or the next click reads a finger that has long\n          // since lifted.\n          onPointerCancel={tap.drop}\n          onKeyDown={(event) => {\n            tap.drop();\n            if (event.key === \"Escape\") hide();\n          }}\n          onClick={toggleOnTap}\n        >\n          {trigger}\n        </span>\n      ) : null}\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open ? (\n                <TooltipPositioner\n                  key=\"tooltip\"\n                  anchorRef={anchorRef}\n                  floatingRef={floatingRef}\n                  anchorPoint={anchorPoint}\n                  followCursor={followCursor}\n                  side={side}\n                  onDismiss={hide}\n                  pointer={pointer}\n                >\n                  {(positioned, isPresent) => (\n                    <TooltipSurface\n                      id={id}\n                      ready={positioned}\n                      side={side}\n                      onPointerEnter={() => {\n                        if (timer.current) clearTimeout(timer.current);\n                      }}\n                      onPointerLeave={leave}\n                      style={{\n                        maxWidth: \"calc(100vw - 16px)\",\n                        whiteSpace: \"normal\",\n                        pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\",\n                      }}\n                      className={cn(\"overflow-hidden\", className)}\n                    >\n                      {content}\n                    </TooltipSurface>\n                  )}\n                </TooltipPositioner>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","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":"components/motion/checkbox.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  /** Associates an external message (e.g. a form error) with the control. */\n  \"aria-describedby\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": ariaDescribedBy,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        aria-describedby={ariaDescribedBy}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  animate,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child?.props?.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)\n    ?.ref;\n  // Register once per actual ref change, not once per open-state render.\n  const mergedRef = useMemo(\n    () => mergeRefs(childRef, ctx.registerTrigger),\n    [childRef, ctx.registerTrigger],\n  );\n  if (!isValidElement(children)) return children;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergedRef,\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipAt(side: Side, align: Align, radius: number, inset: number) {\n  const top = side === \"bottom\" ? \"0%\" : `${inset}%`;\n  const bottom = side === \"bottom\" ? `${inset}%` : \"0%\";\n  const right = align === \"end\" ? \"0%\" : `${inset}%`;\n  const left = align === \"end\" ? `${inset}%` : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  className?: string;\n}\n\nexport function MorphPopoverContent(props: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const [portalReady, setPortalReady] = useState(false);\n  useEffect(() => setPortalReady(true), []);\n  if (!portalReady) return null;\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open && <MorphPopoverSurface {...props} />}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n// Measurement belongs to the mounted portal session: reopening must not start\n// an entrance at the previous session's coordinates before measuring this one.\nfunction MorphPopoverSurface({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [isPresent, safeToRemove] = usePresence();\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    isPresent,\n  );\n\n  const left = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  const top = layout\n    ? side === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : layout.trigger.top - layout.content.height - sideOffset\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { scale: 0.96, transition: SPRING_PANEL },\n        show: { scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipAt(side, align, radius, 92),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipAt(side, align, radius, 0),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n  // Animate the value directly so opacity stays in the inline style throughout\n  // the entrance. A native opacity animation can expose the initial inline 0\n  // for a frame when it finishes, before Motion writes the final value.\n  const opacity = useMotionValue(0);\n  const ready = layout !== null;\n  useEffect(() => {\n    if (!ready) {\n      if (!isPresent) safeToRemove?.();\n      return;\n    }\n    const animation = animate(opacity, isPresent ? 1 : 0, {\n      ...(reduce ? { duration: 0.12 } : SPRING_PANEL),\n      onComplete: () => {\n        if (!isPresent) safeToRemove?.();\n      },\n    });\n    return () => animation.stop();\n  }, [opacity, ready, isPresent, reduce, safeToRemove]);\n\n  return (\n    <motion.div\n      data-morph-popover-portal=\"\"\n      inert={!isPresent}\n      // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n      // clipped shape below (box-shadow would just get clipped away).\n      variants={wrap}\n      initial=\"hidden\"\n      animate={layout ? \"show\" : \"hidden\"}\n      exit=\"hidden\"\n      style={{\n        left,\n        top,\n        opacity,\n        pointerEvents: isPresent ? \"auto\" : \"none\",\n        visibility: layout ? \"visible\" : \"hidden\",\n        transformOrigin: originFor(side, align),\n      }}\n      className=\"fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\"\n    >\n      <motion.div\n        ref={ctx.contentRef}\n        id={ctx.contentId}\n        role=\"dialog\"\n        aria-labelledby={ctx.triggerId}\n        variants={clip}\n        style={{ borderRadius: radius }}\n        className={cn(\n          \"overflow-hidden border border-border bg-background\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/select.tsx","type":"util","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst INSTANT_TRANSITION: Transition = { duration: 0 };\n\n// Spring with bounce powers the unfold/separation; per-property timings in the\n// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.\nconst CHEVRON_TRANSITION: Transition = { type: \"spring\", duration: 0.4, bounce: 0.3 };\n\nconst LIST_VARIANTS: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },\n};\nconst ITEM_VARIANTS: Variants = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  reduce: boolean;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<Map<string, string>>(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      setOpen(false);\n    },\n    [controlled, onValueChange, setOpen],\n  );\n\n  const register = useCallback((v: string, label: string) => {\n    setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen]);\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)),\n      reduce,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      reduce,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // edge facing the panel flattens then rounds; the far edge stays rounded.\n  // All four corners are specified so none gets stranded when placement flips.\n  const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];\n  const kfT: Transition = ctx.reduce\n    ? { duration: 0 }\n    : ctx.open\n      ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }\n      : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      // Gooey: the edge facing the panel snaps flat (panel attached) then rounds\n      // back once the panel pulls away — the two pinch apart.\n      initial={false}\n      animate={{\n        borderTopLeftRadius: isTop ? kf : 12,\n        borderTopRightRadius: isTop ? kf : 12,\n        borderBottomLeftRadius: isTop ? 12 : kf,\n        borderBottomRightRadius: isTop ? 12 : kf,\n      }}\n      transition={{\n        borderTopLeftRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderTopRightRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderBottomLeftRadius: isTop ? INSTANT_TRANSITION : kfT,\n        borderBottomRightRadius: isTop ? INSTANT_TRANSITION : kfT,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        animate={{ rotate: ctx.open ? 180 : 0 }}\n        transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const open = ctx.open;\n  const { setPlacement } = ctx;\n\n  useLayoutEffect(() => {\n    const node = innerRef.current;\n    if (!node) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  });\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = innerRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  // Specify EVERY corner + both margins each render. The near edge (facing the\n  // trigger) animates flat->round and the gap opens on that side; the far edge\n  // stays rounded and its margin pinned to 0. Setting all of them avoids a\n  // stranded square corner when the placement flips between opens.\n  const isTop = ctx.placement === \"top\";\n  const nearGap = open ? 8 : 0;\n  const nearRadius = open ? 12 : 0;\n\n  const gapT: Transition = open\n    ? { type: \"spring\", duration: 0.6, bounce: 0.5, delay: 0.12 }\n    : { type: \"spring\", duration: 0.3, bounce: 0.1 };\n  const radiusT: Transition = open\n    ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }\n    : { duration: 0.16, ease: EASE_OUT };\n\n  // Items stay mounted (open just animates the panel) so each item's label\n  // registration persists — otherwise the trigger would fall back to the\n  // placeholder the moment the panel closes.\n  return (\n    <motion.div\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        ctx.reduce\n          ? { opacity: open ? 1 : 0, height: open ? height : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              height: open ? height : 0,\n              // gap opens on the side facing the trigger\n              marginTop: isTop ? 0 : nearGap,\n              marginBottom: isTop ? nearGap : 0,\n              // near corners go flat->round; far corners stay rounded\n              borderTopLeftRadius: isTop ? 12 : nearRadius,\n              borderTopRightRadius: isTop ? 12 : nearRadius,\n              borderBottomLeftRadius: isTop ? nearRadius : 12,\n              borderBottomRightRadius: isTop ? nearRadius : 12,\n            }\n      }\n      transition={\n        ctx.reduce\n          ? { duration: 0.12 }\n          : {\n              opacity: open\n                ? { duration: 0.18 }\n                : { duration: 0.16, delay: 0.12 },\n              height: open\n                ? { type: \"spring\", duration: 0.42, bounce: 0.14 }\n                : { duration: 0.26, ease: EASE_OUT, delay: 0.14 },\n              marginTop: isTop ? INSTANT_TRANSITION : gapT,\n              marginBottom: isTop ? gapT : INSTANT_TRANSITION,\n              borderTopLeftRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderTopRightRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderBottomLeftRadius: isTop ? radiusT : INSTANT_TRANSITION,\n              borderBottomRightRadius: isTop ? radiusT : INSTANT_TRANSITION,\n            }\n      }\n      style={{\n        transformOrigin: isTop ? \"bottom\" : \"top\",\n        overflow: \"hidden\",\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={innerRef}\n        variants={ctx.reduce ? undefined : LIST_VARIANTS}\n        initial={false}\n        animate={open ? \"show\" : \"hidden\"}\n        className=\"p-1\"\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label);\n    return () => ctx.unregister(value);\n  }, [ctx.register, ctx.unregister, value, label]);\n\n  return (\n    <motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.li>\n  );\n}\n"},{"path":"components/motion/tooltip/positioner.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport { useCallback, useState, type ReactNode } from \"react\";\nimport { useTooltipPosition } from \"./use-position\";\n\ntype PositionProps = Parameters<typeof useTooltipPosition>[0];\n\n/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */\nexport function TooltipPositioner({\n  children,\n  ...position\n}: Omit<PositionProps, \"open\" | \"onPosition\"> & {\n  children: (ready: boolean, present: boolean) => ReactNode;\n}) {\n  const present = useIsPresent();\n  const [ready, setReady] = useState(false);\n  const onPosition = useCallback(() => setReady(true), []);\n  useTooltipPosition({ ...position, open: present, onPosition });\n  return (\n    <span\n      ref={position.floatingRef}\n      inert={!present}\n      aria-hidden={!present || undefined}\n      className=\"pointer-events-none fixed left-0 top-0 z-[9999] w-max\"\n      style={{ visibility: \"hidden\", maxWidth: \"calc(100vw - 16px)\" }}\n    >\n      {children(ready, present)}\n    </span>\n  );\n}\n"},{"path":"components/motion/tooltip/use-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n  type Placement,\n  type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { useCallback, useLayoutEffect, useRef, type RefObject } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipPoint = { x: number; y: number };\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n  open,\n  anchorRef,\n  floatingRef,\n  anchorPoint,\n  followCursor,\n  side,\n  onDismiss,\n  onPosition,\n  pointer,\n}: {\n  open: boolean;\n  anchorRef: RefObject<HTMLElement | SVGElement | null>;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  anchorPoint?: TooltipPoint;\n  followCursor: boolean;\n  side: TooltipSide;\n  onDismiss: () => void;\n  onPosition: () => void;\n  pointer: ReturnType<typeof useTooltipPointer>;\n}) {\n  const { cursor, onMove } = pointer;\n  const cursorSide = useRef<Placement | null>(null);\n  useLayoutEffect(() => {\n    cursorSide.current = open ? side : null;\n  }, [open, side]);\n  const frame = useRef<number | null>(null);\n  const version = useRef(0);\n  const update = useRef<() => void>(() => {});\n  const schedule = useCallback(() => {\n    // Invalidate older async calculations as soon as new geometry is requested.\n    version.current++;\n    if (frame.current !== null) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = null;\n      update.current();\n    });\n  }, []);\n  useLayoutEffect(() => {\n    onMove.current = schedule;\n    return () => {\n      onMove.current = null;\n    };\n  }, [onMove, schedule]);\n  const pointX = anchorPoint?.x;\n  const pointY = anchorPoint?.y;\n\n  // Latest committed inputs are read without recreating observers for every period/content update.\n  useLayoutEffect(() => {\n    update.current = () => {\n      const anchor = anchorRef.current;\n      const floating = floatingRef.current;\n      if (!open || !anchor || !floating) return;\n      const revision = ++version.current;\n      const currentCursor = followCursor ? cursor.current : null;\n      const reference: Element | VirtualElement =\n        currentCursor || pointX !== undefined || pointY !== undefined\n          ? {\n              contextElement: anchor,\n              getBoundingClientRect: () => {\n                const rect = anchor.getBoundingClientRect();\n                const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);\n                const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);\n                return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };\n              },\n            }\n          : anchor;\n      void computePosition(reference, floating, {\n        strategy: \"fixed\",\n        placement: currentCursor ? (cursorSide.current ?? side) : side,\n        middleware: [offset(currentCursor ? 12 : 8), flip({ padding: 8 }), shift({ padding: 8 })],\n      }).then(({ x, y, placement }) => {\n        if (version.current !== revision || !floating.isConnected) return;\n        // Hold the chosen side for this hover session. Crossing a flip threshold\n        // repeatedly must not bounce the surface above and below the pointer.\n        if (currentCursor) cursorSide.current = placement;\n        else cursorSide.current = null;\n        const dpr = window.devicePixelRatio || 1;\n        floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;\n        const origin = {\n          top: \"center bottom\",\n          bottom: \"center top\",\n          left: \"right center\",\n          right: \"left center\",\n        };\n        floating.style.setProperty(\n          \"--tooltip-origin\",\n          origin[placement.split(\"-\")[0] as TooltipSide],\n        );\n        floating.style.visibility = \"visible\";\n        floating.dataset.placement = placement;\n        onPosition();\n      });\n    };\n    if (open) schedule();\n  }, [\n    open,\n    anchorRef,\n    floatingRef,\n    followCursor,\n    pointX,\n    pointY,\n    side,\n    schedule,\n    onPosition,\n    cursor,\n  ]);\n\n  useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    const floating = floatingRef.current;\n    if (!open || !anchor || !floating) return;\n    const stop = autoUpdate(anchor, floating, schedule);\n    const onScroll = () => {\n      // A stationary pointer no longer describes the same chart point after scrolling.\n      if (followCursor && cursor.current) onDismiss();\n    };\n    window.addEventListener(\"scroll\", onScroll, true);\n    return () => {\n      stop();\n      window.removeEventListener(\"scroll\", onScroll, true);\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n      frame.current = null;\n    };\n  }, [open, anchorRef, floatingRef, followCursor, onDismiss, schedule, cursor]);\n\n  useLayoutEffect(\n    () => () => {\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n}\n\n/** Pointer lifetime belongs to the trigger, including the opening delay. */\nexport function useTooltipPointer(\n  anchorRef: RefObject<HTMLElement | SVGElement | null>,\n  followCursor: boolean,\n) {\n  const cursor = useRef<TooltipPoint | null>(null);\n  const onMove = useRef<(() => void) | null>(null);\n  const pointerFocus = useRef(false);\n  useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    if (!anchor || !followCursor) return;\n    const point = (event: PointerEvent) => {\n      if (event.type === \"pointermove\" && event.pointerType === \"touch\" && !event.buttons) return;\n      cursor.current = { x: event.clientX, y: event.clientY };\n      if (event.type === \"pointerdown\") pointerFocus.current = true;\n      onMove.current?.();\n    };\n    const keyboard = () => {\n      cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const focus = () => {\n      if (!pointerFocus.current) cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const leave = () => {\n      cursor.current = null;\n    };\n    anchor.addEventListener(\"pointerenter\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointermove\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerdown\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerleave\", leave);\n    anchor.addEventListener(\"pointercancel\", leave);\n    anchor.addEventListener(\"keydown\", keyboard);\n    anchor.addEventListener(\"focusin\", focus);\n    return () => {\n      cursor.current = null;\n      anchor.removeEventListener(\"pointerenter\", point as EventListener);\n      anchor.removeEventListener(\"pointermove\", point as EventListener);\n      anchor.removeEventListener(\"pointerdown\", point as EventListener);\n      anchor.removeEventListener(\"pointerleave\", leave);\n      anchor.removeEventListener(\"pointercancel\", leave);\n      anchor.removeEventListener(\"keydown\", keyboard);\n      anchor.removeEventListener(\"focusin\", focus);\n    };\n  }, [anchorRef, followCursor]);\n\n  return { cursor, onMove };\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** Presentation only: the unanimated parent owns measurement and positioning. */\nexport function TooltipSurface({\n  children,\n  side: _side = \"top\",\n  className,\n  ref,\n  ready = true,\n  style,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  /** Start the entrance only after the positioning layer has been measured. */\n  ready?: boolean;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      initial={closed}\n      animate={{\n        ...(ready ? { opacity: 1, scale: 1 } : closed),\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }}\n      exit={{ ...closed, transition: { duration: 0.12, ease: EASE_OUT } }}\n      style={{ transformOrigin: \"var(--tooltip-origin, center)\", ...style }}\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","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 | SVGElement | 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-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"components/motion/popover-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"},{"path":"lib/touch.ts","type":"util","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 * 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/previews/blocks/availability-scheduler.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { AvailabilityScheduler } from \"@/components/motion/availability-scheduler\";\n\nexport function AvailabilitySchedulerPreview() {\n  return (\n    <div className=\"flex w-full justify-center\">\n      <AvailabilityScheduler />\n    </div>\n  );\n}\n"}]}