{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"date-time-picker","type":"registry:component","title":"Date Picker Date & Time Picker","description":"One field whose panel morphs open with the calendar beside time wheels; edits a draft and commits on Done, labelled like \"Oct 14, 2026, 9:30 AM\".","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/date-time-picker.tsx","type":"registry:component","target":"@components/motion/date-time-picker.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/date-picker\n\nimport { CalendarClock } from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { Calendar } from \"@/components/motion/calendar\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n  MorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport {\n  type HourCycle,\n  TimePicker,\n  type TimeValue,\n} from \"@/components/motion/time-picker\";\nimport { cn } from \"@/lib/utils\";\n\nconst MEDIUM_DATE_SHORT_TIME: Intl.DateTimeFormatOptions = {\n  dateStyle: \"medium\",\n  timeStyle: \"short\",\n};\n\nexport interface DateTimePickerProps {\n  /** Controlled committed date and time; null shows the placeholder. */\n  value?: Date | null;\n  defaultValue?: Date | null;\n  /** Fires once, when Done is pressed. */\n  onValueChange?: (date: Date) => void;\n  placeholder?: string;\n  /** Intl options for the trigger label. Default `{ dateStyle: \"medium\",\n   * timeStyle: \"short\" }` → \"Oct 14, 2026, 9:30 AM\" in en-US. */\n  formatOptions?: Intl.DateTimeFormatOptions;\n  /** Default \"en-US\". */\n  locale?: string;\n  /** 12 adds an AM/PM wheel; 24 shows 00–23. Also sets the label's clock. Default 12. */\n  hourCycle?: HourCycle;\n  /** Minute wheel granularity. Default 5. */\n  minuteStep?: number;\n  /** Where the wheels start while the field is empty. Default 09:00. */\n  initialTime?: TimeValue;\n  /** Earliest moment. Days before it are disabled, and a committed value\n   * earlier on that same day is clamped up to it. */\n  minDate?: Date;\n  /** Latest moment, clamped the same way. */\n  maxDate?: Date;\n  isDateDisabled?: (date: Date) => boolean;\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  align?: \"start\" | \"end\";\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\ntype Draft = { date: Date | null; time: TimeValue };\n\nconst combine = (date: Date, time: TimeValue) =>\n  new Date(\n    date.getFullYear(),\n    date.getMonth(),\n    date.getDate(),\n    time.hours,\n    time.minutes,\n  );\n\n/**\n * One field for a date and a time. Its panel morphs open from the trigger with\n * the calendar on the left and time wheels on the right (stacked below `sm`).\n * Picking a day and turning the wheels edit a draft; Done commits it, Escape\n * or an outside click discards it. Controlled or uncontrolled.\n */\nexport function DateTimePicker({\n  value: controlledValue,\n  defaultValue = null,\n  onValueChange,\n  placeholder = \"Pick a date and time\",\n  formatOptions = MEDIUM_DATE_SHORT_TIME,\n  locale = \"en-US\",\n  hourCycle = 12,\n  minuteStep = 5,\n  initialTime = { hours: 9, minutes: 0 },\n  minDate,\n  maxDate,\n  isDateDisabled,\n  weekStartsOn,\n  align = \"start\",\n  disabled,\n  className,\n  \"aria-label\": ariaLabel = \"Choose date and time\",\n}: DateTimePickerProps) {\n  const [open, setOpen] = useState(false);\n  const [internal, setInternal] = useState<Date | null>(defaultValue);\n  const value = controlledValue !== undefined ? controlledValue : internal;\n  const [draft, setDraft] = useState<Draft>({ date: null, time: initialTime });\n\n  const label = useMemo(\n    () =>\n      value\n        ? new Intl.DateTimeFormat(locale, {\n            ...formatOptions,\n            hourCycle: hourCycle === 12 ? \"h12\" : \"h23\",\n          }).format(value)\n        : null,\n    [value, locale, formatOptions, hourCycle],\n  );\n\n  const onOpenChange = (next: boolean) => {\n    if (next)\n      setDraft(\n        value\n          ? {\n              date: value,\n              time: { hours: value.getHours(), minutes: value.getMinutes() },\n            }\n          : { date: null, time: initialTime },\n      );\n    setOpen(next);\n  };\n\n  const done = () => {\n    if (!draft.date) return;\n    let next = combine(draft.date, draft.time);\n    if (minDate && next < minDate) next = new Date(minDate);\n    if (maxDate && next > maxDate) next = new Date(maxDate);\n    if (controlledValue === undefined) setInternal(next);\n    onValueChange?.(next);\n    setOpen(false);\n  };\n\n  return (\n    <MorphPopover open={open} onOpenChange={onOpenChange}>\n      <MorphPopoverTrigger>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}\n          className={cn(\n            \"inline-flex h-10 min-w-56 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n            label ? \"text-foreground\" : \"text-muted-foreground\",\n            className,\n          )}\n        >\n          <CalendarClock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n          <span className=\"flex-1 truncate tabular-nums\">\n            {label ?? placeholder}\n          </span>\n        </button>\n      </MorphPopoverTrigger>\n\n      <MorphPopoverContent align={align} className=\"p-0\">\n        <DateTimePanel\n          draft={draft}\n          setDraft={setDraft}\n          onDone={done}\n          locale={locale}\n          hourCycle={hourCycle}\n          minuteStep={minuteStep}\n          minDate={minDate}\n          maxDate={maxDate}\n          isDateDisabled={isDateDisabled}\n          weekStartsOn={weekStartsOn}\n        />\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n\ntype DateTimePanelProps = {\n  draft: Draft;\n  setDraft: (update: (prev: Draft) => Draft) => void;\n  onDone: () => void;\n  locale: string;\n  hourCycle: HourCycle;\n  minuteStep: number;\n  minDate?: Date;\n  maxDate?: Date;\n  isDateDisabled?: (date: Date) => boolean;\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n};\n\nfunction DateTimePanel({\n  draft,\n  setDraft,\n  onDone,\n  locale,\n  hourCycle,\n  minuteStep,\n  minDate,\n  maxDate,\n  isDateDisabled,\n  weekStartsOn,\n}: DateTimePanelProps) {\n  const ref = useRef<HTMLDivElement>(null);\n\n  // Land keyboard focus on the calendar's roving day once the morph panel has\n  // measured itself (it is `visibility: hidden` for the first frame or two).\n  useEffect(() => {\n    let frame = 0;\n    let tries = 0;\n    const attempt = () => {\n      const el = ref.current?.querySelector<HTMLElement>(\n        'button[data-date][tabindex=\"0\"]',\n      );\n      if (el) el.focus({ preventScroll: true });\n      if ((!el || document.activeElement !== el) && tries++ < 10)\n        frame = requestAnimationFrame(attempt);\n    };\n    frame = requestAnimationFrame(attempt);\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const summary = draft.date\n    ? new Intl.DateTimeFormat(locale, {\n        dateStyle: \"medium\",\n        timeStyle: \"short\",\n        hourCycle: hourCycle === 12 ? \"h12\" : \"h23\",\n      }).format(combine(draft.date, draft.time))\n    : \"Pick a day\";\n\n  return (\n    <div ref={ref} className=\"flex flex-col\">\n      <div className=\"flex flex-col sm:flex-row\">\n        <Calendar\n          value={draft.date}\n          onValueChange={(date) => setDraft((prev) => ({ ...prev, date }))}\n          locale={locale}\n          minDate={minDate}\n          maxDate={maxDate}\n          isDateDisabled={isDateDisabled}\n          weekStartsOn={weekStartsOn}\n          className=\"border-0 bg-transparent\"\n        />\n        <div className=\"flex flex-col items-center justify-center gap-2 border-border border-t p-3 sm:border-t-0 sm:border-l\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            Time\n          </span>\n          <TimePicker\n            value={draft.time}\n            onValueChange={(time) => setDraft((prev) => ({ ...prev, time }))}\n            hourCycle={hourCycle}\n            minuteStep={minuteStep}\n            className=\"bg-muted/40\"\n          />\n        </div>\n      </div>\n      <div className=\"flex items-center justify-between gap-3 border-border border-t px-3 py-2\">\n        <span\n          className={cn(\n            \"truncate text-sm tabular-nums\",\n            draft.date ? \"font-medium text-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {summary}\n        </span>\n        <button\n          type=\"button\"\n          onClick={onDone}\n          disabled={!draft.date}\n          className=\"inline-flex h-8 shrink-0 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40\"\n        >\n          Done\n        </button>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/calendar.tsx","type":"registry:component","target":"@components/motion/calendar.tsx","content":"\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DateRange = { from: Date; to?: Date };\n/** A whole week row: `from` is its first day, `to` its seventh. */\nexport type WeekRange = { from: Date; to: Date };\n\n/** Per-day extras from `getDayMeta`. */\nexport type CalendarDayMeta = {\n  /** Short text under the day number (a price, a count). Cells grow taller\n   * when any day has meta; the label is also read after the date. */\n  label?: string;\n  /** \"accent\" tints the label (deals, highlights); \"muted\" fades it. */\n  tone?: \"muted\" | \"accent\";\n  /** Blocks the day and strikes it through, like `isDateDisabled`. */\n  disabled?: boolean;\n};\n\ntype CalendarBaseProps = {\n  /** Earliest selectable day (inclusive). */\n  minDate?: Date;\n  /** Latest selectable day (inclusive). */\n  maxDate?: Date;\n  /** Extra per-day rule, e.g. weekends or booked dates. */\n  isDateDisabled?: (date: Date) => boolean;\n  /** 0 = Sunday … 6 = Saturday. Default 0. */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** BCP 47 locale for month, weekday and day labels. Default \"en-US\" — a\n   * fixed default keeps server and client output identical for hydration. */\n  locale?: string;\n  /** Controlled visible month (any day inside it). */\n  month?: Date;\n  /** Uncontrolled initial visible month. Default: the selection or today. */\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  /** Months shown side by side (stacked below `sm`). Default 1. With more\n   * than one, days outside each month are left blank so none repeats. */\n  numberOfMonths?: number;\n  /** Adds an ISO 8601 week-number column (\"Wk\") before each row. */\n  showWeekNumber?: boolean;\n  /** Per-day label, tone and blocking, e.g. nightly prices or booked days.\n   * Passing it switches the grid to taller cells that fit the label. */\n  getDayMeta?: (date: Date) => CalendarDayMeta | null | undefined;\n  className?: string;\n  \"aria-label\"?: string;\n};\n\nexport type CalendarSingleProps = CalendarBaseProps & {\n  mode?: \"single\";\n  value?: Date | null;\n  defaultValue?: Date | null;\n  onValueChange?: (date: Date) => void;\n};\n\nexport type CalendarRangeProps = CalendarBaseProps & {\n  mode: \"range\";\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  onValueChange?: (range: DateRange) => void;\n  /** Fewest nights (days between start and end) a finished range may span.\n   * While the end is being chosen, days too close are blocked. */\n  minNights?: number;\n  /** Most nights a finished range may span; farther days are blocked. */\n  maxNights?: number;\n};\n\nexport type CalendarWeekProps = CalendarBaseProps & {\n  mode: \"week\";\n  value?: WeekRange | null;\n  defaultValue?: WeekRange | null;\n  /** Called with the picked day's whole row, honouring `weekStartsOn`. */\n  onValueChange?: (week: WeekRange) => void;\n};\n\nexport type CalendarMultipleProps = CalendarBaseProps & {\n  mode: \"multiple\";\n  value?: Date[] | null;\n  defaultValue?: Date[] | null;\n  /** Called with the selection sorted by date. */\n  onValueChange?: (dates: Date[]) => void;\n  /** Most days that can be picked; further picks are ignored. */\n  max?: number;\n};\n\nexport type CalendarProps =\n  | CalendarSingleProps\n  | CalendarRangeProps\n  | CalendarWeekProps\n  | CalendarMultipleProps;\n\n// ── date helpers (local time, day precision) ──────────────────────────────\n\nconst startOfDay = (d: Date) =>\n  new Date(d.getFullYear(), d.getMonth(), d.getDate());\nconst startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);\nconst addDays = (d: Date, n: number) =>\n  new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\n/** Adds months, clamping the day so Jan 31 + 1 month lands on Feb 28/29. */\nfunction addMonths(d: Date, n: number) {\n  const target = new Date(d.getFullYear(), d.getMonth() + n, 1);\n  const lastDay = new Date(\n    target.getFullYear(),\n    target.getMonth() + 1,\n    0,\n  ).getDate();\n  target.setDate(Math.min(d.getDate(), lastDay));\n  return target;\n}\nconst sameDay = (a?: Date | null, b?: Date | null) =>\n  !!a &&\n  !!b &&\n  a.getFullYear() === b.getFullYear() &&\n  a.getMonth() === b.getMonth() &&\n  a.getDate() === b.getDate();\nconst sameMonth = (a: Date, b: Date) =>\n  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\nconst dayKey = (d: Date) =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\nconst monthKey = (d: Date) =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}`;\n/** Whole days from `a` to `b`; rounding absorbs DST hour shifts. */\nconst daysBetween = (a: Date, b: Date) =>\n  Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 86_400_000);\n/** ISO 8601 week number: weeks start Monday, week 1 holds Jan 4. */\nfunction isoWeek(d: Date) {\n  const thursday = addDays(d, 3 - ((d.getDay() + 6) % 7));\n  const jan4 = new Date(thursday.getFullYear(), 0, 4);\n  const firstThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));\n  return 1 + Math.round(daysBetween(firstThursday, thursday) / 7);\n}\n\n/** Always 6 weeks, so the panel never changes height between months. */\nfunction buildWeeks(month: Date, weekStartsOn: number) {\n  const first = startOfMonth(month);\n  const offset = (first.getDay() - weekStartsOn + 7) % 7;\n  const start = addDays(first, -offset);\n  return Array.from({ length: 6 }, (_, w) =>\n    Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),\n  );\n}\n\n// Month swap is a short directional slide; the exit is quicker than the entry.\nconst MONTH_ENTER = { duration: 0.26, ease: EASE_OUT } as const;\nconst MONTH_EXIT = { duration: 0.16, ease: EASE_OUT } as const;\n\n/**\n * Month-grid calendar for single dates, ranges, whole weeks or several\n * separate days, one or more months wide. The selected day is a pill that\n * glides between days on a shared-layout spring; a picked week is one pill\n * across its row; months slide in from the direction you travel. Optional ISO\n * week numbers, per-day labels (`getDayMeta`) and night limits for ranges.\n * Full keyboard grid navigation (arrows, Home/End, PageUp/PageDown,\n * Shift+Page for years). Controlled or uncontrolled for both the selection\n * and the visible month; reduced-motion safe.\n */\nexport function Calendar(props: CalendarProps) {\n  const {\n    minDate,\n    maxDate,\n    isDateDisabled,\n    weekStartsOn = 0,\n    locale = \"en-US\",\n    month: controlledMonth,\n    defaultMonth,\n    onMonthChange,\n    numberOfMonths = 1,\n    showWeekNumber = false,\n    getDayMeta,\n    className,\n    \"aria-label\": ariaLabel,\n  } = props;\n  const isRange = props.mode === \"range\";\n  const isWeek = props.mode === \"week\";\n  const isMultiple = props.mode === \"multiple\";\n  const count = Math.max(1, Math.floor(numberOfMonths));\n  const minNights = isRange ? ((props as CalendarRangeProps).minNights ?? 0) : 0;\n  const maxNights = isRange ? (props as CalendarRangeProps).maxNights : undefined;\n  // Taller cells only when there is something to put under the number.\n  const tall = !!getDayMeta;\n\n  const uid = useId();\n  const reduce = useReducedMotion();\n\n  // ── selection ──\n  const [internalValue, setInternalValue] = useState<\n    Date | DateRange | WeekRange | Date[] | null\n  >(props.defaultValue ?? null);\n  const valueControlled = props.value !== undefined;\n  const value = valueControlled ? (props.value ?? null) : internalValue;\n  const single =\n    isRange || isWeek || isMultiple ? null : (value as Date | null);\n  const range = isRange ? (value as DateRange | null) : null;\n  const week = isWeek ? (value as WeekRange | null) : null;\n  const picked = isMultiple ? ((value as Date[] | null) ?? []) : [];\n  const isPicked = (d: Date) => picked.some((p) => sameDay(p, d));\n\n  // ── visible months ──\n  const anchor = single ?? range?.from ?? week?.from ?? picked[0] ?? null;\n  const [internalMonth, setInternalMonth] = useState(() =>\n    startOfMonth(defaultMonth ?? anchor ?? new Date()),\n  );\n  const month = controlledMonth\n    ? startOfMonth(controlledMonth)\n    : internalMonth;\n  const [direction, setDirection] = useState(1);\n  const lastMonth = addMonths(month, count - 1);\n  const inWindow = (d: Date) => {\n    const m = startOfMonth(d);\n    return m >= month && m <= lastMonth;\n  };\n\n  const setMonth = useCallback(\n    (next: Date) => {\n      const m = startOfMonth(next);\n      if (sameMonth(m, month)) return;\n      setDirection(m > month ? 1 : -1);\n      if (!controlledMonth) setInternalMonth(m);\n      onMonthChange?.(m);\n    },\n    [month, controlledMonth, onMonthChange],\n  );\n\n  // \"Today\" is read after mount: the server's clock and timezone can disagree\n  // with the viewer's, and a mismatched marker would break hydration.\n  const [today, setToday] = useState<Date | null>(null);\n  useEffect(() => setToday(startOfDay(new Date())), []);\n\n  // ── roving focus ──\n  const [focused, setFocused] = useState<Date>(() =>\n    startOfDay(anchor ?? defaultMonth ?? new Date()),\n  );\n  const gridRef = useRef<HTMLDivElement>(null);\n  const shouldFocus = useRef(false);\n  // Range hover preview: the band follows the pointer until the end is picked.\n  // In week mode it is the row under the pointer or keyboard focus.\n  const [hovered, setHovered] = useState<Date | null>(null);\n\n  // Keep the tab stop inside the visible months when they change by buttons.\n  const focusInMonth = inWindow(focused)\n    ? focused\n    : anchor && inWindow(anchor)\n      ? anchor\n      : month;\n\n  useEffect(() => {\n    if (!shouldFocus.current) return;\n    shouldFocus.current = false;\n    gridRef.current\n      ?.querySelector<HTMLButtonElement>(\n        `[data-month=\"${monthKey(focusInMonth)}\"] [data-date=\"${dayKey(focusInMonth)}\"]`,\n      )\n      ?.focus();\n  }, [focusInMonth]);\n\n  const isDisabled = useCallback(\n    (d: Date) =>\n      (minDate && d < startOfDay(minDate)) ||\n      (maxDate && d > startOfDay(maxDate)) ||\n      !!isDateDisabled?.(d) ||\n      !!getDayMeta?.(d)?.disabled,\n    [minDate, maxDate, isDateDisabled, getDayMeta],\n  );\n\n  // While a range end is being chosen, days that would make it too short or\n  // too long are blocked (earlier days stay open: they restart the range).\n  const choosingEnd = !!range && !range.to;\n  const nightsAllowed = (n: number) =>\n    n >= minNights && (maxNights === undefined || n <= maxNights);\n  const blockedByNights = (d: Date) =>\n    choosingEnd &&\n    !!range &&\n    d > range.from &&\n    !nightsAllowed(daysBetween(range.from, d));\n\n  const weekStart = (d: Date) =>\n    addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));\n\n  const select = (d: Date) => {\n    if (isDisabled(d) || blockedByNights(d)) return;\n    if (!inWindow(d)) setMonth(d);\n    setFocused(d);\n\n    if (isWeek) {\n      const from = weekStart(d);\n      const next: WeekRange = { from, to: addDays(from, 6) };\n      if (!valueControlled) setInternalValue(next);\n      (props as CalendarWeekProps).onValueChange?.(next);\n      return;\n    }\n\n    if (isMultiple) {\n      const max = (props as CalendarMultipleProps).max;\n      const next = isPicked(d)\n        ? picked.filter((p) => !sameDay(p, d))\n        : max !== undefined && picked.length >= max\n          ? null\n          : [...picked, d].sort((a, b) => a.getTime() - b.getTime());\n      if (!next) return;\n      if (!valueControlled) setInternalValue(next);\n      (props as CalendarMultipleProps).onValueChange?.(next);\n      return;\n    }\n    if (!isRange) {\n      if (!valueControlled) setInternalValue(d);\n      (props as CalendarSingleProps).onValueChange?.(d);\n      return;\n    }\n    // First click starts a range; a later day closes it; an earlier day, the\n    // start again when it would be too short, or a click on a finished range\n    // starts over.\n    const next: DateRange =\n      range &&\n      !range.to &&\n      d >= range.from &&\n      nightsAllowed(daysBetween(range.from, d))\n        ? { from: range.from, to: d }\n        : { from: d };\n    if (!valueControlled) setInternalValue(next);\n    (props as CalendarRangeProps).onValueChange?.(next);\n  };\n\n  const onGridKeyDown = (e: KeyboardEvent) => {\n    const base = focusInMonth;\n    let next: Date | null = null;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        next = addDays(base, -1);\n        break;\n      case \"ArrowRight\":\n        next = addDays(base, 1);\n        break;\n      case \"ArrowUp\":\n        next = addDays(base, -7);\n        break;\n      case \"ArrowDown\":\n        next = addDays(base, 7);\n        break;\n      case \"Home\":\n        next = addDays(base, -((base.getDay() - weekStartsOn + 7) % 7));\n        break;\n      case \"End\":\n        next = addDays(base, 6 - ((base.getDay() - weekStartsOn + 7) % 7));\n        break;\n      case \"PageUp\":\n        next = addMonths(base, e.shiftKey ? -12 : -1);\n        break;\n      case \"PageDown\":\n        next = addMonths(base, e.shiftKey ? 12 : 1);\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    shouldFocus.current = true;\n    setFocused(next);\n    // Scroll the window only when focus leaves it, by as little as needed.\n    if (!inWindow(next))\n      setMonth(next < month ? next : addMonths(startOfMonth(next), 1 - count));\n  };\n\n  // ── labels ──\n  const monthFormat = useMemo(\n    () => new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n    [locale],\n  );\n  const weekdays = useMemo(() => {\n    const short = new Intl.DateTimeFormat(locale, { weekday: \"short\" });\n    const long = new Intl.DateTimeFormat(locale, { weekday: \"long\" });\n    // 2023-01-01 was a Sunday.\n    return Array.from({ length: 7 }, (_, i) => {\n      const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));\n      return { short: short.format(d).slice(0, 2), long: long.format(d) };\n    });\n  }, [locale, weekStartsOn]);\n  const dayLabel = useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        weekday: \"long\",\n        day: \"numeric\",\n        month: \"long\",\n        year: \"numeric\",\n      }),\n    [locale],\n  );\n\n  const prevDisabled =\n    !!minDate && startOfMonth(minDate) >= month;\n  const nextDisabled =\n    !!maxDate && startOfMonth(maxDate) <= lastMonth;\n\n  // Range band end: the picked end, or the hovered day while choosing one.\n  // A day that can't close the range doesn't stretch the band to it.\n  const bandEnd =\n    range &&\n    !range.to &&\n    hovered &&\n    hovered > range.from &&\n    !isDisabled(hovered) &&\n    !blockedByNights(hovered)\n      ? hovered\n      : range?.to;\n  // Week mode: the row being previewed (pointer or keyboard focus).\n  const previewWeek = isWeek && hovered ? weekStart(hovered) : null;\n\n  const navButton =\n    \"inline-flex size-8 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\n  const months = Array.from({ length: count }, (_, i) => addMonths(month, i));\n  // Weekday index of the column, so connecting bands stop at the row ends.\n  const column = (d: Date) => (d.getDay() - weekStartsOn + 7) % 7;\n  const cellSize = tall ? \"h-12 w-10\" : \"size-9\";\n\n  return (\n    <div\n      className={cn(\n        \"w-fit rounded-2xl border border-border bg-card p-3 text-foreground\",\n        className,\n      )}\n    >\n      <div\n        ref={gridRef}\n        onPointerLeave={() => setHovered(null)}\n        className=\"flex flex-col gap-x-6 gap-y-4 sm:flex-row\"\n      >\n        {months.map((m, i) => {\n          const mk = monthKey(m);\n          const weeks = buildWeeks(m, weekStartsOn);\n          const labelId = `${uid}-label-${i}`;\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: columns are positional; each keeps its own month transition.\n              key={i}\n              className=\"min-w-0\"\n            >\n              <div className=\"mb-2 flex items-center justify-between gap-2 px-1\">\n                {i === 0 ? (\n                  <button\n                    type=\"button\"\n                    aria-label=\"Previous month\"\n                    disabled={prevDisabled}\n                    onClick={() => setMonth(addMonths(month, -1))}\n                    className={navButton}\n                  >\n                    <ChevronLeft className=\"h-4 w-4\" />\n                  </button>\n                ) : (\n                  <span aria-hidden className=\"size-8\" />\n                )}\n                <h2\n                  id={labelId}\n                  aria-live={i === 0 ? \"polite\" : undefined}\n                  className=\"text-sm font-medium tabular-nums\"\n                >\n                  {monthFormat.format(m)}\n                </h2>\n                {i === count - 1 ? (\n                  <button\n                    type=\"button\"\n                    aria-label=\"Next month\"\n                    disabled={nextDisabled}\n                    onClick={() => setMonth(addMonths(month, 1))}\n                    className={navButton}\n                  >\n                    <ChevronRight className=\"h-4 w-4\" />\n                  </button>\n                ) : (\n                  <span aria-hidden className=\"size-8\" />\n                )}\n              </div>\n\n              <div className=\"relative overflow-hidden\">\n                <AnimatePresence\n                  initial={false}\n                  mode=\"popLayout\"\n                  custom={direction}\n                >\n                  <motion.table\n                    key={mk}\n                    data-month={mk}\n                    custom={direction}\n                    aria-labelledby={ariaLabel ? undefined : labelId}\n                    aria-label={ariaLabel ? `${ariaLabel}, ${monthFormat.format(m)}` : undefined}\n                    onKeyDown={onGridKeyDown}\n                    variants={{\n                      enter: (dir: number) => ({\n                        opacity: 0,\n                        x: reduce ? 0 : dir * 28,\n                        filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n                      }),\n                      center: {\n                        opacity: 1,\n                        x: 0,\n                        filter: \"blur(0px)\",\n                        transition: MONTH_ENTER,\n                      },\n                      exit: (dir: number) => ({\n                        opacity: 0,\n                        x: reduce ? 0 : dir * -28,\n                        filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n                        transition: MONTH_EXIT,\n                      }),\n                    }}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                    className=\"border-collapse\"\n                  >\n                    <thead>\n                      <tr>\n                        {showWeekNumber && (\n                          <th\n                            scope=\"col\"\n                            abbr=\"Week\"\n                            className=\"h-8 w-8 text-[11px] font-medium text-muted-foreground/70\"\n                          >\n                            Wk\n                          </th>\n                        )}\n                        {weekdays.map((w) => (\n                          <th\n                            key={w.long}\n                            scope=\"col\"\n                            abbr={w.long}\n                            className={cn(\n                              \"h-8 text-[11px] font-medium text-muted-foreground\",\n                              tall ? \"w-10\" : \"w-9\",\n                            )}\n                          >\n                            {w.short}\n                          </th>\n                        ))}\n                      </tr>\n                    </thead>\n                    <tbody>\n                      {weeks.map((row) => {\n                        // Days this row actually draws: side-by-side months\n                        // leave the other month's days blank.\n                        const shown =\n                          count > 1 ? row.filter((d) => sameMonth(d, m)) : row;\n                        const weekSelected =\n                          !!week && sameDay(row[0], weekStart(week.from));\n                        const weekPreview =\n                          !weekSelected && sameDay(row[0], previewWeek);\n                        // ISO weeks run Monday–Sunday; the row's Thursday\n                        // decides which one a differently started row is.\n                        const thursday =\n                          row.find((d) => d.getDay() === 4) ?? row[0];\n                        return (\n                          <tr key={dayKey(row[0])}>\n                            {showWeekNumber && (\n                              <th\n                                scope=\"row\"\n                                className={cn(\n                                  \"p-0 text-center text-[11px] font-normal tabular-nums transition-colors\",\n                                  weekSelected\n                                    ? \"font-medium text-foreground\"\n                                    : \"text-muted-foreground/70\",\n                                )}\n                              >\n                                {shown.length > 0 ? isoWeek(thursday) : null}\n                              </th>\n                            )}\n                            {row.map((d) => {\n                              const outside = !sameMonth(d, m);\n                              // Side-by-side months would show these days twice.\n                              if (outside && count > 1)\n                                return (\n                                  <td key={dayKey(d)} className=\"p-0\">\n                                    <span className={cn(\"block\", cellSize)} />\n                                  </td>\n                                );\n                              const meta = getDayMeta?.(d) ?? null;\n                              const disabled = isDisabled(d);\n                              const nightsBlocked =\n                                !disabled && blockedByNights(d);\n                              const isToday = sameDay(d, today);\n                              const isFrom = sameDay(d, range?.from);\n                              const isTo = sameDay(d, bandEnd);\n                              const inBand =\n                                !!range?.from &&\n                                !!bandEnd &&\n                                d > range.from &&\n                                d < bandEnd;\n                              const chosen = isMultiple && isPicked(d);\n                              const inWeek =\n                                !!week &&\n                                d >= startOfDay(week.from) &&\n                                d <= startOfDay(week.to);\n                              const selected = isRange\n                                ? isFrom || (isTo && !!range?.to)\n                                : isWeek\n                                  ? inWeek\n                                  : isMultiple\n                                    ? chosen\n                                    : sameDay(d, single);\n                              const endpoint = isFrom || isTo;\n                              // Picked neighbours in the same row merge into one\n                              // pill, so a run of days reads as a block.\n                              const joinPrev =\n                                chosen &&\n                                column(d) > 0 &&\n                                sameMonth(addDays(d, -1), m) &&\n                                isPicked(addDays(d, -1));\n                              const joinNext =\n                                chosen &&\n                                column(d) < 6 &&\n                                sameMonth(addDays(d, 1), m) &&\n                                isPicked(addDays(d, 1));\n                              // The week pill lives in the row's first drawn\n                              // cell and spans every drawn day after it.\n                              const weekPill =\n                                isWeek &&\n                                (weekSelected || weekPreview) &&\n                                sameDay(d, shown[0]);\n\n                              return (\n                                <td key={dayKey(d)} className=\"relative p-0\">\n                                  {(inBand ||\n                                    (endpoint &&\n                                      bandEnd &&\n                                      !sameDay(range?.from, bandEnd))) && (\n                                    <span\n                                      aria-hidden\n                                      className={cn(\n                                        \"absolute inset-y-0.5 bg-primary/10\",\n                                        inBand && \"inset-x-0\",\n                                        isFrom && \"left-1/2 right-0\",\n                                        isTo && \"left-0 right-1/2\",\n                                      )}\n                                    />\n                                  )}\n                                  {weekPill && (\n                                    <motion.span\n                                      aria-hidden\n                                      // Selected and previewed rows each glide\n                                      // between weeks, scoped per month.\n                                      layoutId={`${uid}-${mk}-${weekSelected ? \"week\" : \"week-preview\"}`}\n                                      transition={\n                                        reduce ? { duration: 0 } : SPRING_LAYOUT\n                                      }\n                                      style={{ width: `${shown.length * 100}%` }}\n                                      className={cn(\n                                        \"pointer-events-none absolute inset-y-0 left-0 rounded-lg\",\n                                        weekSelected ? \"bg-primary\" : \"bg-muted\",\n                                      )}\n                                    />\n                                  )}\n                                  <button\n                                    type=\"button\"\n                                    data-date={dayKey(d)}\n                                    tabIndex={\n                                      sameDay(d, focusInMonth) && !outside\n                                        ? 0\n                                        : -1\n                                    }\n                                    aria-label={\n                                      meta?.label\n                                        ? `${dayLabel.format(d)}, ${meta.label}`\n                                        : dayLabel.format(d)\n                                    }\n                                    aria-pressed={selected}\n                                    aria-disabled={\n                                      disabled || nightsBlocked || undefined\n                                    }\n                                    aria-current={isToday ? \"date\" : undefined}\n                                    title={\n                                      nightsBlocked\n                                        ? daysBetween(range?.from ?? d, d) <\n                                          minNights\n                                          ? `Minimum ${minNights} nights`\n                                          : `Maximum ${maxNights} nights`\n                                        : undefined\n                                    }\n                                    onClick={() => select(d)}\n                                    onFocus={() => {\n                                      setFocused(d);\n                                      if (isWeek) setHovered(d);\n                                    }}\n                                    onBlur={() => isWeek && setHovered(null)}\n                                    onPointerEnter={() =>\n                                      (isRange || isWeek) && setHovered(d)\n                                    }\n                                    className={cn(\n                                      \"relative isolate flex items-center justify-center rounded-lg text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                                      cellSize,\n                                      tall && \"flex-col gap-0.5\",\n                                      outside\n                                        ? \"text-muted-foreground/50\"\n                                        : \"text-foreground\",\n                                      !selected &&\n                                        !disabled &&\n                                        !nightsBlocked &&\n                                        !isWeek &&\n                                        \"hover:bg-muted\",\n                                      selected && \"text-primary-foreground\",\n                                      selected &&\n                                        outside &&\n                                        \"text-primary-foreground/60\",\n                                      isTo && !range?.to && \"text-foreground\",\n                                      nightsBlocked &&\n                                        \"cursor-not-allowed text-muted-foreground/40\",\n                                      disabled &&\n                                        \"cursor-not-allowed text-muted-foreground/40 line-through\",\n                                    )}\n                                  >\n                                    {selected &&\n                                      !isWeek &&\n                                      (isRange || isMultiple ? (\n                                        <motion.span\n                                          aria-hidden\n                                          initial={\n                                            reduce ? false : { scale: 0.7 }\n                                          }\n                                          animate={{ scale: 1 }}\n                                          transition={SPRING_PRESS}\n                                          className={cn(\n                                            \"absolute inset-0 -z-10 rounded-lg bg-primary\",\n                                            joinPrev && \"rounded-l-none\",\n                                            joinNext && \"rounded-r-none\",\n                                          )}\n                                        />\n                                      ) : (\n                                        <motion.span\n                                          aria-hidden\n                                          // Scoped per month so the pill glides\n                                          // between days but never across the\n                                          // sliding grids.\n                                          layoutId={`${uid}-${mk}-selected`}\n                                          transition={\n                                            reduce\n                                              ? { duration: 0 }\n                                              : SPRING_LAYOUT\n                                          }\n                                          className=\"absolute inset-0 -z-10 rounded-lg bg-primary\"\n                                        />\n                                      ))}\n                                    {isTo && !range?.to && (\n                                      <span\n                                        aria-hidden\n                                        className=\"absolute inset-0 -z-10 rounded-lg border border-primary/40\"\n                                      />\n                                    )}\n                                    {tall ? (\n                                      <span className=\"leading-none\">\n                                        {d.getDate()}\n                                      </span>\n                                    ) : (\n                                      d.getDate()\n                                    )}\n                                    {tall && (\n                                      <span\n                                        aria-hidden\n                                        className={cn(\n                                          \"h-2.5 text-[10px] leading-none\",\n                                          meta?.tone === \"accent\"\n                                            ? // Tinted toward the foreground so\n                                              // the green keeps contrast on\n                                              // light and dark cards.\n                                              \"font-medium text-[color:color-mix(in_oklab,var(--chart-3)_70%,var(--foreground))]\"\n                                            : meta?.tone === \"muted\"\n                                              ? \"text-muted-foreground/60\"\n                                              : \"text-muted-foreground\",\n                                          selected &&\n                                            \"text-primary-foreground/75\",\n                                          (disabled || nightsBlocked) &&\n                                            \"text-muted-foreground/40\",\n                                        )}\n                                      >\n                                        {meta?.label}\n                                      </span>\n                                    )}\n                                    {isToday && (\n                                      <span\n                                        aria-hidden\n                                        className={cn(\n                                          \"absolute size-1 rounded-full\",\n                                          tall ? \"top-1.5 right-1.5\" : \"bottom-1\",\n                                          selected\n                                            ? \"bg-primary-foreground\"\n                                            : \"bg-primary\",\n                                        )}\n                                      />\n                                    )}\n                                  </button>\n                                </td>\n                              );\n                            })}\n                          </tr>\n                        );\n                      })}\n                    </tbody>\n                  </motion.table>\n                </AnimatePresence>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"registry:component","target":"@components/motion/popover-morph.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } 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  if (!isValidElement(children)) return children;\n\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> }).ref;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergeRefs(childRef, ctx.registerTrigger),\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 clipHidden(side: Side, align: Align, radius: number) {\n  const top = side === \"bottom\" ? \"0%\" : \"92%\";\n  const bottom = side === \"bottom\" ? \"92%\" : \"0%\";\n  const right = align === \"end\" ? \"0%\" : \"92%\";\n  const left = align === \"end\" ? \"92%\" : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\nconst clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;\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({\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 [portalReady, setPortalReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    portalReady && ctx.open,\n  );\n\n  useEffect(() => setPortalReady(true), []);\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: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },\n        show: { opacity: 1, scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipHidden(side, align, radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipShown(radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n\n  // Keep the server and first client render identical, then mount the portal.\n  if (!portalReady) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open ? (\n        <motion.div\n          data-morph-popover-portal=\"\"\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={reduce ? { opacity: 0 } : \"hidden\"}\n          animate={reduce ? { opacity: 1 } : \"show\"}\n          exit={reduce ? { opacity: 0 } : \"hidden\"}\n          transition={reduce ? { duration: 0.12 } : undefined}\n          style={{\n            left,\n            top,\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      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/time-picker.tsx","type":"registry:component","target":"@components/motion/time-picker.tsx","content":"\"use client\";\n\nimport { Clock } from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n  MorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { WheelPicker } from \"@/components/motion/wheel-picker\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A wall-clock time, always 24-hour internally: `hours` 0–23, `minutes` 0–59.\n * The 12/24-hour choice is display only, so a value never changes meaning when\n * `hourCycle` does. Use `parseTime` / `toTimeString` for \"HH:mm\" strings.\n */\nexport type TimeValue = { hours: number; minutes: number };\n\nexport type HourCycle = 12 | 24;\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\n/** \"09:30\" → { hours: 9, minutes: 30 }. Returns null for anything malformed. */\nexport function parseTime(input: string): TimeValue | null {\n  const match = /^(\\d{1,2}):(\\d{2})$/.exec(input.trim());\n  if (!match) return null;\n  const hours = Number(match[1]);\n  const minutes = Number(match[2]);\n  if (hours > 23 || minutes > 59) return null;\n  return { hours, minutes };\n}\n\n/** { hours: 9, minutes: 30 } → \"09:30\" (24-hour, zero padded). */\nexport function toTimeString(time: TimeValue) {\n  return `${pad(time.hours)}:${pad(time.minutes)}`;\n}\n\n/** Locale label for a time, e.g. \"9:30 AM\" (en-US, 12h) or \"09:30\" (24h). */\nexport function formatTime(\n  time: TimeValue,\n  { locale = \"en-US\", hourCycle = 12 }: { locale?: string; hourCycle?: HourCycle } = {},\n) {\n  // A fixed calendar day: only the clock fields are read, and a constant date\n  // keeps the output identical on server and client.\n  return new Intl.DateTimeFormat(locale, {\n    hour: \"numeric\",\n    minute: \"2-digit\",\n    hourCycle: hourCycle === 12 ? \"h12\" : \"h23\",\n  }).format(new Date(2000, 0, 1, time.hours, time.minutes));\n}\n\n/** Snaps minutes onto the step grid the minute wheel offers. */\nfunction snapMinutes(minutes: number, step: number) {\n  const last = Math.floor(59 / step) * step;\n  return Math.min(Math.round(minutes / step) * step, last);\n}\n\nexport interface TimePickerProps {\n  /** Controlled time (24-hour). */\n  value?: TimeValue;\n  /** Uncontrolled initial time. Default 09:00. */\n  defaultValue?: TimeValue;\n  /** Fires as each wheel settles on a row. Always 24-hour. */\n  onValueChange?: (time: TimeValue) => void;\n  /** 12 shows hour 12–11 plus an AM/PM wheel; 24 shows 00–23. Default 12. */\n  hourCycle?: HourCycle;\n  /** Minute wheel granularity. A value off the grid displays snapped to the\n   * nearest step and the first wheel turn commits the snapped minute. Default 5. */\n  minuteStep?: number;\n  /** Rows visible per wheel, odd. Default 5. */\n  visibleCount?: number;\n  /** Row height in px. Default 36. */\n  itemHeight?: number;\n  /** Tick sound per row crossed (WheelPicker's `sound`). Default false. */\n  sound?: boolean;\n  disabled?: boolean;\n  className?: string;\n  /** Prefix for each wheel's accessible name. Default \"Time\". */\n  \"aria-label\"?: string;\n}\n\nconst DEFAULT_TIME: TimeValue = { hours: 9, minutes: 0 };\n\n/**\n * iOS-style time wheels: hour, minute and (for 12-hour) an AM/PM drum, each a\n * `WheelPicker`, so drag/flick, mouse wheel and keyboard (↑/↓, Home/End on a\n * focused wheel) all come from the shared drum. Controlled or uncontrolled;\n * the value is always 24-hour `{ hours, minutes }`.\n */\nexport function TimePicker({\n  value: controlledValue,\n  defaultValue = DEFAULT_TIME,\n  onValueChange,\n  hourCycle = 12,\n  minuteStep = 5,\n  visibleCount = 5,\n  itemHeight = 36,\n  sound = false,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel = \"Time\",\n}: TimePickerProps) {\n  const [internal, setInternal] = useState<TimeValue>(defaultValue);\n  const controlled = controlledValue !== undefined;\n  const value = controlled ? controlledValue : internal;\n  const step = Math.max(1, Math.min(30, Math.floor(minuteStep)));\n\n  // Each wheel reports on its own, sometimes several times in one frame while\n  // dragging; composing from the latest committed time (not the render\n  // closure) keeps a quick hour turn from being undone by the minute wheel.\n  const latest = useRef(value);\n  useEffect(() => {\n    latest.current = value;\n  }, [value]);\n\n  const commit = (patch: Partial<TimeValue>) => {\n    const next = { ...latest.current, ...patch };\n    if (\n      next.hours === latest.current.hours &&\n      next.minutes === latest.current.minutes\n    )\n      return;\n    latest.current = next;\n    if (!controlled) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const hourOptions = useMemo(\n    () =>\n      hourCycle === 12\n        ? // 12 sits first: 12 AM is hour 0, so the wheel reads 12, 1 … 11.\n          Array.from({ length: 12 }, (_, i) => ({\n            value: String(i),\n            label: String(i === 0 ? 12 : i),\n          }))\n        : Array.from({ length: 24 }, (_, i) => ({\n            value: String(i),\n            label: pad(i),\n          })),\n    [hourCycle],\n  );\n  const minuteOptions = useMemo(\n    () =>\n      Array.from({ length: Math.floor(59 / step) + 1 }, (_, i) => ({\n        value: String(i * step),\n        label: pad(i * step),\n      })),\n    [step],\n  );\n\n  const pm = value.hours >= 12;\n  const hourValue = String(hourCycle === 12 ? value.hours % 12 : value.hours);\n  const minuteValue = String(snapMinutes(value.minutes, step));\n  const hourLabel =\n    hourOptions.find((o) => o.value === hourValue)?.label ?? hourValue;\n\n  const wheel = \"border-0 bg-transparent\";\n\n  return (\n    <div\n      className={cn(\n        \"inline-flex items-center gap-0.5 rounded-2xl border border-border bg-card p-1.5 text-base tabular-nums\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n    >\n      <WheelPicker\n        options={hourOptions}\n        value={hourValue}\n        onValueChange={(v) => {\n          const h = Number(v);\n          commit({\n            hours:\n              hourCycle === 12 ? h + (latest.current.hours >= 12 ? 12 : 0) : h,\n          });\n        }}\n        visibleCount={visibleCount}\n        itemHeight={itemHeight}\n        sound={sound}\n        disabled={disabled}\n        className={cn(wheel, \"w-16\")}\n        aria-label={`${ariaLabel}, hour ${hourLabel}`}\n      />\n      <span aria-hidden className=\"-mx-1 font-medium text-muted-foreground\">\n        :\n      </span>\n      <WheelPicker\n        options={minuteOptions}\n        value={minuteValue}\n        onValueChange={(v) => commit({ minutes: Number(v) })}\n        visibleCount={visibleCount}\n        itemHeight={itemHeight}\n        sound={sound}\n        disabled={disabled}\n        className={cn(wheel, \"w-16\")}\n        aria-label={`${ariaLabel}, minute ${pad(Number(minuteValue))}`}\n      />\n      {hourCycle === 12 && (\n        <WheelPicker\n          options={PERIODS}\n          value={pm ? \"PM\" : \"AM\"}\n          onValueChange={(v) => {\n            const h = latest.current.hours % 12;\n            commit({ hours: v === \"PM\" ? h + 12 : h });\n          }}\n          visibleCount={visibleCount}\n          itemHeight={itemHeight}\n          sound={sound}\n          disabled={disabled}\n          className={cn(wheel, \"w-16 text-sm\")}\n          aria-label={`${ariaLabel}, ${pm ? \"PM\" : \"AM\"}`}\n        />\n      )}\n    </div>\n  );\n}\n\nconst PERIODS = [\"AM\", \"PM\"];\n\n/**\n * Moves focus to the first focusable node inside `container` once the morph\n * popover has measured itself — it stays `visibility: hidden` for a frame or\n * two, and focusing a hidden element silently fails.\n */\nfunction useFocusFirst(\n  container: React.RefObject<HTMLElement | null>,\n  selector: string,\n) {\n  useEffect(() => {\n    let frame = 0;\n    let tries = 0;\n    const attempt = () => {\n      const el = container.current?.querySelector<HTMLElement>(selector);\n      if (el) el.focus({ preventScroll: true });\n      if ((!el || document.activeElement !== el) && tries++ < 10)\n        frame = requestAnimationFrame(attempt);\n    };\n    frame = requestAnimationFrame(attempt);\n    return () => cancelAnimationFrame(frame);\n  }, [container, selector]);\n}\n\nexport interface TimeFieldProps\n  extends Omit<TimePickerProps, \"value\" | \"defaultValue\" | \"onValueChange\" | \"className\"> {\n  /** Controlled committed time; null shows the placeholder. */\n  value?: TimeValue | null;\n  defaultValue?: TimeValue | null;\n  /** Fires once, when Done is pressed. Always 24-hour. */\n  onValueChange?: (time: TimeValue) => void;\n  /** Where the wheels start while the field is empty. Default 09:00. */\n  initialTime?: TimeValue;\n  placeholder?: string;\n  /** Default \"en-US\". */\n  locale?: string;\n  align?: \"start\" | \"end\";\n  className?: string;\n}\n\n/**\n * Time field whose wheels morph open from the trigger corner. Turning the\n * wheels edits a draft; Done commits it, Escape or an outside click discards\n * it. Controlled or uncontrolled.\n */\nexport function TimeField({\n  value: controlledValue,\n  defaultValue = null,\n  onValueChange,\n  initialTime = DEFAULT_TIME,\n  placeholder = \"Pick a time\",\n  locale = \"en-US\",\n  hourCycle = 12,\n  align = \"start\",\n  disabled,\n  className,\n  \"aria-label\": ariaLabel = \"Choose time\",\n  ...pickerProps\n}: TimeFieldProps) {\n  const [open, setOpen] = useState(false);\n  const [internal, setInternal] = useState<TimeValue | null>(defaultValue);\n  const value = controlledValue !== undefined ? controlledValue : internal;\n  const [draft, setDraft] = useState<TimeValue>(value ?? initialTime);\n\n  const label = value ? formatTime(value, { locale, hourCycle }) : null;\n\n  const onOpenChange = (next: boolean) => {\n    if (next) setDraft(value ?? initialTime);\n    setOpen(next);\n  };\n\n  const done = () => {\n    if (controlledValue === undefined) setInternal(draft);\n    onValueChange?.(draft);\n    setOpen(false);\n  };\n\n  return (\n    <MorphPopover open={open} onOpenChange={onOpenChange}>\n      <MorphPopoverTrigger>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          aria-label={label ? `${ariaLabel}, ${label}` : ariaLabel}\n          className={cn(\n            \"inline-flex h-10 min-w-40 items-center gap-2 rounded-xl border border-border bg-background px-3 text-left text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n            label ? \"text-foreground\" : \"text-muted-foreground\",\n            className,\n          )}\n        >\n          <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n          <span className=\"flex-1 truncate tabular-nums\">\n            {label ?? placeholder}\n          </span>\n        </button>\n      </MorphPopoverTrigger>\n\n      <MorphPopoverContent align={align} className=\"p-0\">\n        <TimeFieldPanel\n          draft={draft}\n          setDraft={setDraft}\n          onDone={done}\n          hourCycle={hourCycle}\n          locale={locale}\n          {...pickerProps}\n        />\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n\ntype TimeFieldPanelProps = Omit<\n  TimePickerProps,\n  \"value\" | \"defaultValue\" | \"onValueChange\" | \"className\" | \"aria-label\"\n> & {\n  draft: TimeValue;\n  setDraft: (time: TimeValue) => void;\n  onDone: () => void;\n  locale: string;\n};\n\nfunction TimeFieldPanel({\n  draft,\n  setDraft,\n  onDone,\n  hourCycle,\n  locale,\n  ...pickerProps\n}: TimeFieldPanelProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  useFocusFirst(ref, '[role=\"listbox\"]');\n\n  return (\n    <div ref={ref} className=\"flex flex-col gap-2 p-2\">\n      <TimePicker\n        {...pickerProps}\n        value={draft}\n        onValueChange={setDraft}\n        hourCycle={hourCycle}\n        className=\"border-0 bg-transparent p-0\"\n      />\n      <div className=\"flex items-center justify-between gap-3 border-border border-t px-1 pt-2\">\n        <span className=\"text-sm font-medium tabular-nums\">\n          {formatTime(draft, { locale, hourCycle })}\n        </span>\n        <button\n          type=\"button\"\n          onClick={onDone}\n          className=\"inline-flex h-8 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          Done\n        </button>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"components/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","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":"components/motion/wheel-picker.tsx","type":"registry:component","target":"@components/motion/wheel-picker.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createTickPlayer } from \"@/lib/tick-sound\";\nimport {\n  capturePointer,\n  releasePointer,\n  TOUCH_GESTURE_CLASS,\n} from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type WheelPickerOption = string | { label: string; value: string };\n\nexport interface WheelPickerProps {\n  options: WheelPickerOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Rows visible through the window, odd. More = flatter curve. Default 5. */\n  visibleCount?: number;\n  /** Row height in px. Default 36. */\n  itemHeight?: number;\n  disabled?: boolean;\n  /** Play a short tick each time the selected value changes. Default false. */\n  sound?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\nconst DEG = Math.PI / 180;\n// Physics constants, tuned for an iOS-like flick rather than reused from the\n// shared spring tokens: the wheel coasts in whole-row units and springs to an\n// integer detent, which a layout spring can't express cleanly.\nconst DECELERATION = 0.00042; // rows per ms², how fast a flick bleeds off (lower = freer)\nconst MAX_VELOCITY = 0.18; // rows per ms, caps a hard fling\nconst VELOCITY_WINDOW = 90; // ms of recent drag to average a release velocity over\nconst WHEEL_SENS = 0.012; // rows per pixel of wheel delta\nconst WHEEL_SETTLE = 110; // ms of wheel idle before snapping to a row\nconst easeOutCubic = (p: number) => 1 - (1 - p) ** 3;\n// Overshoots the target by a few percent then settles — the little spring\n// bounce as a row snaps home. `BACK` sets how far it drifts past.\nconst BACK = 1.35;\nconst easeOutBack = (p: number) =>\n  1 + (BACK + 1) * (p - 1) ** 3 + BACK * (p - 1) ** 2;\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.max(lo, Math.min(v, hi));\n\nfunction optionValue(option: WheelPickerOption) {\n  return typeof option === \"string\" ? option : option.value;\n}\nfunction optionLabel(option: WheelPickerOption) {\n  return typeof option === \"string\" ? option : option.label;\n}\n\nexport function WheelPicker({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  visibleCount = 5,\n  itemHeight = 36,\n  disabled = false,\n  sound = false,\n  className,\n  \"aria-label\": ariaLabel,\n}: WheelPickerProps) {\n  const reduce = useReducedMotion() ?? false;\n  const controlled = value !== undefined;\n  const last = options.length - 1;\n\n  const indexOf = useCallback(\n    (v: string | undefined) => {\n      const i = options.findIndex((o) => optionValue(o) === v);\n      return i < 0 ? 0 : i;\n    },\n    [options],\n  );\n\n  const [internal, setInternal] = useState(() => defaultValue ?? value);\n  const currentValue = controlled ? value : internal;\n  const [grabbing, setGrabbing] = useState(false);\n\n  // Cylinder geometry. Each row spans `itemAngle`; `radius` seats the rows on\n  // the drum; rows past `hideBeyond` sit behind the horizon and are dropped.\n  const { itemAngle, radius, height, hideBeyond } = useMemo(() => {\n    const rowsEachSide = Math.max(1, Math.floor(visibleCount / 2));\n    const cutoff = rowsEachSide + 1;\n    const angle = 90 / cutoff;\n    const r = itemHeight / Math.tan(angle * DEG);\n    return {\n      itemAngle: angle,\n      radius: r,\n      hideBeyond: cutoff,\n      height: Math.round(\n        2 * r * Math.sin(rowsEachSide * angle * DEG) + itemHeight,\n      ),\n    };\n  }, [visibleCount, itemHeight]);\n\n  const container = useRef<HTMLDivElement>(null);\n  const drumRef = useRef<HTMLUListElement>(null);\n  const bandRef = useRef<HTMLUListElement>(null);\n  // Scroll position measured in rows (a float index). One source of truth for\n  // both layers: the drum rotates by `itemAngle·scroll`, the crisp band slides\n  // by `itemHeight·scroll`.\n  const scroll = useRef(indexOf(currentValue));\n  const raf = useRef(0);\n  const emitted = useRef(currentValue);\n  const tickPlayer = useRef<ReturnType<typeof createTickPlayer> | null>(null);\n  const lastTick = useRef(indexOf(currentValue));\n\n  const paint = useCallback(\n    (s: number) => {\n      const drum = drumRef.current;\n      const band = bandRef.current;\n      if (drum) {\n        drum.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;\n        for (const node of Array.from(drum.children)) {\n          const li = node as HTMLLIElement;\n          const i = Number(li.dataset.index);\n          const want = Math.abs(i - s) > hideBeyond ? \"hidden\" : \"visible\";\n          // Write only on change — an unconditional write every frame thrashes\n          // style recalc and is what made the drag feel draggy on mobile.\n          if (li.style.visibility !== want) li.style.visibility = want;\n        }\n      }\n      // The band is the SAME drum, clipped to the centre row — driven by the\n      // identical transform so the crisp copy sits exactly on the dimmed one,\n      // with no parallax ghost as rows cross the window. It needs the same\n      // horizon cull, or the row on the back of the drum bleeds into the front.\n      if (band) {\n        band.style.transform = `translateZ(${-radius}px) rotateX(${itemAngle * s}deg)`;\n        for (const node of Array.from(band.children)) {\n          const li = node as HTMLLIElement;\n          const i = Number(li.dataset.index);\n          const want = Math.abs(i - s) > hideBeyond ? \"hidden\" : \"visible\";\n          if (li.style.visibility !== want) li.style.visibility = want;\n        }\n      }\n    },\n    [radius, itemAngle, hideBeyond],\n  );\n\n  const getPlayer = useCallback(() => {\n    if (!tickPlayer.current) tickPlayer.current = createTickPlayer();\n    return tickPlayer.current;\n  }, []);\n\n  const emit = useCallback(\n    (i: number) => {\n      const v = optionValue(options[clamp(i, 0, last)]);\n      if (v === emitted.current) return;\n      emitted.current = v;\n      // Reduced motion has no drum/glide path to tick from — it's an\n      // onClick straight into emit, so the tick lives here instead.\n      if (sound && reduce) getPlayer().play();\n      if (!controlled) setInternal(v);\n      onValueChange?.(v);\n    },\n    [options, last, controlled, onValueChange, sound, reduce, getPlayer],\n  );\n\n  // Drum path: fires a tick whenever the nearest row changes, independent of\n  // `emit` (which only fires on settle during a glide/fling, not per row\n  // crossed). Gated on `!reduce` since the reduced render never calls this.\n  const maybeTick = useCallback(\n    (pos: number) => {\n      const row = clamp(Math.round(pos), 0, last);\n      if (!sound || reduce) {\n        lastTick.current = row;\n        return;\n      }\n      if (row === lastTick.current) return;\n      lastTick.current = row;\n      getPlayer().play();\n    },\n    [sound, reduce, last, getPlayer],\n  );\n\n  const stop = useCallback(() => cancelAnimationFrame(raf.current), []);\n\n  // Ease `scroll` from where it is to an integer detent over `duration`. The\n  // easing may overshoot (spring bounce) before it resolves exactly on `to`.\n  const glide = useCallback(\n    (\n      to: number,\n      duration: number,\n      ease: (p: number) => number = easeOutCubic,\n    ) => {\n      stop();\n      const from = scroll.current;\n      const dist = to - from;\n      if (!dist || duration <= 0) {\n        scroll.current = to;\n        paint(to);\n        maybeTick(to);\n        emit(to);\n        return;\n      }\n      const start = performance.now();\n      const tick = (now: number) => {\n        const p = (now - start) / duration;\n        if (p >= 1) {\n          scroll.current = to;\n          paint(to);\n          maybeTick(to);\n          emit(to);\n          return;\n        }\n        scroll.current = from + dist * ease(p);\n        paint(scroll.current);\n        maybeTick(scroll.current);\n        raf.current = requestAnimationFrame(tick);\n      };\n      raf.current = requestAnimationFrame(tick);\n    },\n    [stop, paint, emit, maybeTick],\n  );\n\n  // Project where a flick of `velocity` (rows/ms) coasts to, snap to a row.\n  const fling = useCallback(\n    (velocity: number) => {\n      const from = scroll.current;\n      if (from < 0 || from > last) {\n        glide(clamp(Math.round(from), 0, last), 260); // rubber-band back\n        return;\n      }\n      const dir = Math.sign(velocity);\n      const coast = ((velocity * velocity) / (2 * DECELERATION)) * dir;\n      const to = clamp(Math.round(from + coast), 0, last);\n      // Long, spring-tipped settle so a hard flick reads as free momentum\n      // coasting to rest with a gentle bounce, never a clipped stop.\n      const duration = clamp(\n        Math.sqrt(Math.abs(to - from)) * 300 + 240,\n        280,\n        1700,\n      );\n      glide(to, duration, easeOutBack);\n    },\n    [glide, last],\n  );\n\n  const step = useCallback(\n    (by: number) =>\n      glide(clamp(Math.round(scroll.current) + by, 0, last), 300, easeOutBack),\n    [glide, last],\n  );\n\n  // Drag: track recent points for a release velocity; rubber-band past the ends.\n  const drag = useRef<{\n    y: number;\n    scroll: number;\n    pts: [number, number][];\n  } | null>(null);\n  // Coalesce touch/pointer moves to one paint per animation frame — raw move\n  // events fire several times per frame (and off-frame) on high-refresh\n  // screens, and painting each one is what made the drag feel choppy.\n  const dragFrame = useRef(0);\n  const latestY = useRef(0);\n  // Shared drag core, driven by a Y coordinate from either a mouse pointer or a\n  // native touch. Touch is bound with non-passive listeners in the effect below\n  // so the move can preventDefault the page scroll — React's synthetic touch\n  // events are passive and can't, which is why finger-drag did nothing on\n  // mobile.\n  const beginDrag = useCallback(\n    (y: number) => {\n      stop();\n      if (sound) getPlayer().prepare();\n      setGrabbing(true);\n      drag.current = {\n        y,\n        scroll: scroll.current,\n        pts: [[y, performance.now()]],\n      };\n    },\n    [stop, sound, getPlayer],\n  );\n  const moveDrag = useCallback(\n    (y: number) => {\n      const d = drag.current;\n      if (!d) return;\n      // Record every sample for an accurate release velocity, but only render\n      // the newest position once per frame.\n      latestY.current = y;\n      d.pts.push([y, performance.now()]);\n      if (d.pts.length > 8) d.pts.shift();\n      if (dragFrame.current) return;\n      dragFrame.current = requestAnimationFrame(() => {\n        dragFrame.current = 0;\n        const dd = drag.current;\n        if (!dd) return;\n        let next = dd.scroll + (dd.y - latestY.current) / itemHeight;\n        if (next < 0) next *= 0.3;\n        else if (next > last) next = last + (next - last) * 0.3;\n        scroll.current = next;\n        paint(next);\n        maybeTick(next);\n        emit(Math.round(clamp(next, 0, last)));\n      });\n    },\n    [itemHeight, last, paint, emit, maybeTick],\n  );\n  const endDrag = useCallback(() => {\n    const d = drag.current;\n    if (!d) return;\n    if (dragFrame.current) {\n      cancelAnimationFrame(dragFrame.current);\n      dragFrame.current = 0;\n    }\n    drag.current = null;\n    setGrabbing(false);\n    // Average velocity over the last `VELOCITY_WINDOW` ms of movement rather\n    // than the final two samples — a single noisy frame otherwise makes an\n    // even flick feel like it caught or slipped.\n    const pts = d.pts;\n    let v = 0;\n    if (pts.length > 1) {\n      const latest = pts[pts.length - 1];\n      let ref = pts[0];\n      for (const p of pts) {\n        if (latest[1] - p[1] <= VELOCITY_WINDOW) {\n          ref = p;\n          break;\n        }\n      }\n      const dt = latest[1] - ref[1];\n      if (dt > 0) {\n        const raw = (ref[0] - latest[0]) / itemHeight / dt;\n        v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY);\n      }\n    }\n    fling(v);\n  }, [itemHeight, fling]);\n\n  // Mouse / pen only — touch runs through the native listeners in the effect.\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled || reduce || event.pointerType === \"touch\") return;\n      beginDrag(event.clientY);\n      capturePointer(event.currentTarget, event.pointerId);\n    },\n    [disabled, reduce, beginDrag],\n  );\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (event.pointerType === \"touch\") return;\n      moveDrag(event.clientY);\n    },\n    [moveDrag],\n  );\n  const onPointerUp = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (event.pointerType === \"touch\") return;\n      // Also the pointercancel handler, where the capture is already gone —\n      // an unguarded release throws there and the drum never settles.\n      releasePointer(event.currentTarget, event.pointerId);\n      endDrag();\n    },\n    [endDrag],\n  );\n\n  // Wheel drives `scroll` continuously — like a drag — then snaps once it goes\n  // idle. Firing a fresh eased step per notch instead stacks overlapping\n  // animations that keep interrupting each other, which read as lag.\n  const wheelSnap = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const onWheel = useCallback(\n    (event: globalThis.WheelEvent) => {\n      // Native (non-passive) so preventDefault actually stops the page from\n      // scrolling behind the wheel — React's synthetic wheel listener is\n      // passive, so a handler on the element cannot cancel the scroll.\n      if (disabled || reduce) return;\n      event.preventDefault();\n      if (sound) getPlayer().prepare();\n      stop();\n      const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;\n      const next = clamp(scroll.current + px * WHEEL_SENS, 0, last);\n      scroll.current = next;\n      paint(next);\n      maybeTick(next);\n      emit(Math.round(next));\n      if (wheelSnap.current) clearTimeout(wheelSnap.current);\n      wheelSnap.current = setTimeout(() => {\n        glide(clamp(Math.round(scroll.current), 0, last), 240, easeOutBack);\n      }, WHEEL_SETTLE);\n    },\n    [\n      disabled,\n      reduce,\n      sound,\n      last,\n      paint,\n      emit,\n      stop,\n      glide,\n      maybeTick,\n      getPlayer,\n    ],\n  );\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const at = Math.round(scroll.current);\n      const map: Record<string, number> = {\n        ArrowUp: -1,\n        ArrowDown: 1,\n        Home: -at,\n        End: last - at,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        if (sound) getPlayer().prepare();\n        step(map[event.key]);\n      }\n    },\n    [disabled, sound, last, step, getPlayer],\n  );\n\n  // Paint the starting frame, and follow controlled/value changes from outside\n  // unless a gesture is mid-flight.\n  useEffect(() => {\n    if (drag.current) return;\n    const target = indexOf(currentValue);\n    emitted.current = currentValue;\n    if (Math.abs(Math.round(scroll.current) - target) < 0.001) {\n      paint(scroll.current);\n      return;\n    }\n    glide(target, 260);\n  }, [currentValue, indexOf, paint, glide]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(raf.current);\n      cancelAnimationFrame(dragFrame.current);\n      if (wheelSnap.current) clearTimeout(wheelSnap.current);\n      tickPlayer.current?.dispose();\n    },\n    [],\n  );\n\n  // Native touch + wheel listeners, bound non-passively so touchmove and wheel\n  // can block the page from scrolling while the wheel spins. React's synthetic\n  // touch/wheel handlers are passive, so preventDefault there is a no-op and the\n  // gesture scrolls the page instead of driving the drum.\n  useEffect(() => {\n    const el = container.current;\n    if (!el || reduce || disabled) return;\n    const onStart = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (t) beginDrag(t.clientY);\n    };\n    const onMove = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (!t || !drag.current) return;\n      e.preventDefault();\n      moveDrag(t.clientY);\n    };\n    const onEnd = () => endDrag();\n    el.addEventListener(\"touchstart\", onStart, { passive: true });\n    el.addEventListener(\"touchmove\", onMove, { passive: false });\n    el.addEventListener(\"touchend\", onEnd);\n    el.addEventListener(\"touchcancel\", onEnd);\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      el.removeEventListener(\"touchstart\", onStart);\n      el.removeEventListener(\"touchmove\", onMove);\n      el.removeEventListener(\"touchend\", onEnd);\n      el.removeEventListener(\"touchcancel\", onEnd);\n      el.removeEventListener(\"wheel\", onWheel);\n    };\n  }, [reduce, disabled, beginDrag, moveDrag, endDrag, onWheel]);\n\n  const maskFade =\n    \"[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]\";\n\n  if (reduce) {\n    const pad = (height - itemHeight) / 2;\n    return (\n      <div\n        className={cn(\n          \"relative overflow-hidden rounded-2xl border border-border bg-card\",\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        style={{ height }}\n      >\n        <div\n          className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 border-border border-y bg-foreground/[0.04]\"\n          style={{ height: itemHeight }}\n        />\n        <ul\n          className={cn(\n            \"h-full snap-y snap-mandatory overflow-y-auto scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n            maskFade,\n          )}\n          style={{ paddingTop: pad, paddingBottom: pad }}\n        >\n          {options.map((option) => {\n            const v = optionValue(option);\n            return (\n              <li key={v} className=\"snap-center\">\n                <button\n                  type=\"button\"\n                  disabled={disabled}\n                  onClick={() => emit(options.indexOf(option))}\n                  className={cn(\n                    \"flex w-full items-center justify-center font-medium\",\n                    v === currentValue\n                      ? \"text-foreground\"\n                      : \"text-muted-foreground\",\n                  )}\n                  style={{ height: itemHeight }}\n                >\n                  {optionLabel(option)}\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={container}\n      role=\"listbox\"\n      aria-label={ariaLabel}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={onKeyDown}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n      className={cn(\n        \"relative touch-none overflow-hidden rounded-2xl border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        // The drum owns the whole gesture; iOS must not open its callout or\n        // start a drag out of the same press and cancel ours.\n        TOUCH_GESTURE_CLASS,\n        grabbing ? \"cursor-grabbing\" : \"cursor-grab\",\n        disabled && \"pointer-events-none opacity-50\",\n        maskFade,\n        className,\n      )}\n      style={{ height, perspective: 1000 }}\n    >\n      {/* Curved drum of dimmed rows. */}\n      <ul\n        ref={drumRef}\n        aria-hidden\n        className=\"absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]\"\n      >\n        {options.map((option, i) => (\n          <li\n            key={optionValue(option)}\n            data-index={i}\n            className=\"absolute inset-x-0 flex items-center justify-center font-medium text-muted-foreground\"\n            style={{\n              top: -itemHeight / 2,\n              height: itemHeight,\n              transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,\n            }}\n          >\n            {optionLabel(option)}\n          </li>\n        ))}\n      </ul>\n\n      {/* Center band: the very same drum, clipped to one row and drawn crisp.\n          Its own perspective, centred on the container middle, matches the main\n          drum's projection so the two copies register exactly. */}\n      <div\n        className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 overflow-hidden rounded-md bg-foreground/[0.04]\"\n        style={{ height: itemHeight, perspective: 1000 }}\n      >\n        <ul\n          ref={bandRef}\n          aria-hidden\n          className=\"absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]\"\n        >\n          {options.map((option, i) => (\n            <li\n              key={optionValue(option)}\n              data-index={i}\n              className=\"absolute inset-x-0 flex items-center justify-center font-medium text-foreground\"\n              style={{\n                top: -itemHeight / 2,\n                height: itemHeight,\n                transform: `rotateX(${-itemAngle * i}deg) translateZ(${radius}px)`,\n              }}\n            >\n              {optionLabel(option)}\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/tick-sound.ts","type":"registry:lib","target":"@lib/tick-sound.ts","content":"// Synthesizes a short, dry mechanical detent for gesture feedback (e.g. the\n// WheelPicker row-cross click) entirely at runtime. Several inharmonic modes\n// make it read as a tiny physical impact instead of an oscillator beep, while\n// the high-passed transient gives it definition through phone speakers.\n\nconst DURATION = 0.016; // seconds, short enough to stay distinct during a fling\nconst MASTER_GAIN = 0.055;\n\nconst MODES = [\n  { frequency: 980, decay: 0.0038, gain: 0.5, phase: 0 },\n  { frequency: 1820, decay: 0.0024, gain: 0.3, phase: 0.65 },\n  { frequency: 3160, decay: 0.00115, gain: 0.12, phase: 1.3 },\n] as const;\n\ntype AudioContextCtor = typeof AudioContext;\n\nfunction getAudioContextCtor(): AudioContextCtor | undefined {\n  if (typeof window === \"undefined\") return undefined;\n  return (\n    window.AudioContext ??\n    (window as unknown as { webkitAudioContext?: AudioContextCtor })\n      .webkitAudioContext\n  );\n}\n\n// One shared context/buffer/gain for the whole page, ref-counted across every\n// `createTickPlayer()` caller. Components like WheelPicker commonly compose\n// several side by side (a date made of month/day/year drums) — giving each\n// its own AudioContext would waste resources and risk hitting a browser's cap\n// on concurrent contexts. The context only closes once every consumer that\n// created a player has called `dispose()`.\nlet ctx: AudioContext | null = null;\nlet masterGain: GainNode | null = null;\nlet clickBuffer: AudioBuffer | null = null;\nlet consumers = 0;\n\nfunction buildClickBuffer(context: AudioContext): AudioBuffer {\n  const sampleRate = context.sampleRate;\n  const length = Math.ceil(DURATION * sampleRate);\n  const buffer = context.createBuffer(1, length, sampleRate);\n  const data = buffer.getChannelData(0);\n\n  let peak = 0;\n  let previousNoise = 0;\n  for (let i = 0; i < length; i++) {\n    const t = i / sampleRate;\n    const attack = Math.min(1, t / 0.00012);\n    const fadeOut = Math.min(1, (DURATION - t) / 0.001);\n    let resonance = 0;\n    for (const mode of MODES) {\n      resonance +=\n        Math.sin(2 * Math.PI * mode.frequency * t + mode.phase) *\n        Math.exp(-t / mode.decay) *\n        mode.gain;\n    }\n\n    // Differencing consecutive noise samples removes the low, papery part of\n    // white noise and leaves only a tiny impact at the front of the sound.\n    const noise = Math.random() * 2 - 1;\n    const transient =\n      (noise - previousNoise) * Math.exp(-t / 0.00038) * 0.035;\n    previousNoise = noise;\n\n    data[i] = (resonance + transient) * attack * fadeOut;\n    peak = Math.max(peak, Math.abs(data[i]));\n  }\n  if (peak > 0) {\n    const norm = 0.9 / peak;\n    for (let i = 0; i < length; i++) data[i] *= norm;\n  }\n\n  return buffer;\n}\n\nfunction ensureContext() {\n  if (ctx) return;\n  const Ctor = getAudioContextCtor();\n  if (!Ctor) return;\n  ctx = new Ctor();\n  masterGain = ctx.createGain();\n  masterGain.gain.value = MASTER_GAIN;\n  masterGain.connect(ctx.destination);\n  clickBuffer = buildClickBuffer(ctx);\n}\n\nfunction prepareContext() {\n  ensureContext();\n  if (ctx?.state === \"suspended\") {\n    // Browsers may reject this when it is not called from a trusted gesture.\n    // Pointer, touch, wheel and keyboard entry points call `prepare()` first;\n    // this repeat attempt keeps direct `play()` calls safe as well.\n    void ctx.resume().catch(() => undefined);\n  }\n}\n\ninterface TickPlayer {\n  prepare: () => void;\n  play: () => void;\n  dispose: () => void;\n}\n\n/**\n * Lazily-initialized tick player. No `AudioContext` is created until the first\n * `prepare()` or `play()` call anywhere on the page. Every player shares the\n * same context/buffer, ref-counted so `dispose()` only tears it down once the\n * last consumer is gone. Safe during SSR or in browsers without Web Audio:\n * `prepare()` and `play()` become silent no-ops.\n */\nexport function createTickPlayer(): TickPlayer {\n  consumers++;\n  let disposed = false;\n  let activeSource: AudioBufferSourceNode | null = null;\n\n  return {\n    prepare() {\n      if (disposed) return;\n      prepareContext();\n    },\n    play() {\n      if (disposed) return;\n      prepareContext();\n      if (!ctx || !masterGain || !clickBuffer) return;\n      // A fast fling can cross rows more quickly than the tail of a tick.\n      // Cutting that tail keeps the feedback dry instead of stacking into a\n      // louder metallic ring.\n      activeSource?.stop();\n      const source = ctx.createBufferSource();\n      source.buffer = clickBuffer;\n      source.connect(masterGain);\n      source.onended = () => {\n        source.disconnect();\n        if (activeSource === source) activeSource = null;\n      };\n      activeSource = source;\n      source.start();\n    },\n    dispose() {\n      if (disposed) return;\n      disposed = true;\n      activeSource?.stop();\n      activeSource = null;\n      consumers = Math.max(0, consumers - 1);\n      if (consumers === 0 && ctx) {\n        ctx.close();\n        ctx = null;\n        masterGain = null;\n        clickBuffer = null;\n      }\n    },\n  };\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"}]}