{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-time-picker",
  "type": "registry:component",
  "title": "Date Time Picker",
  "description": "A compact calendar and time-wheel picker with a softly unfolding popover, AM/PM selection, local timezone readout, Now shortcut and explicit confirmation. Defaults to a 36px trigger, 32px day cells and a 480px desktop panel. Supports keyboard navigation, controlled or uncontrolled local date-time values, forms and reduced motion. Customize individual parts with classNames: trigger, content, calendar, day, selectedDay, time and confirmButton.",
  "author": "Saurabh <saurabh10102@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// asharca.github.io/ui/components/motion/date-time-picker\n\nimport {\n\tCalendarDays,\n\tChevronDown,\n\tChevronLeft,\n\tChevronRight,\n\tClock3,\n} from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n\ttype KeyboardEvent,\n\tuseId,\n\tuseLayoutEffect,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { Button } from \"@/components/motion/button/base\";\nimport {\n\tDAY,\n\tiso,\n\tmonthOf,\n\tparse,\n\tshiftMonth,\n} from \"@/components/motion/date-range-picker/date-utils\";\nimport {\n\tMorphPopover,\n\tMorphPopoverContent,\n\tMorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { WheelPicker } from \"@/components/motion/wheel-picker\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DateTimePickerClassNames = {\n\ttrigger?: string;\n\tcontent?: string;\n\tcalendar?: string;\n\tday?: string;\n\tselectedDay?: string;\n\ttime?: string;\n\tconfirmButton?: string;\n};\n\nexport interface DateTimePickerProps {\n\t/** Local wall-clock time, YYYY-MM-DDTHH:mm. No timezone conversion is applied. */\n\tvalue?: string;\n\tdefaultValue?: string;\n\t/** Called only when Confirm is pressed. Dismissing discards the draft. */\n\tonValueChange?: (value: string) => void;\n\tlabel?: string;\n\tdisabled?: boolean;\n\tname?: string;\n\tclassName?: string;\n\tclassNames?: DateTimePickerClassNames;\n}\n\nconst pad = (value: number) => String(value).padStart(2, \"0\");\nconst HOURS = Array.from({ length: 12 }, (_, index) => pad(index + 1));\nconst MINUTES = Array.from({ length: 60 }, (_, index) => pad(index));\nconst dateFormat = new Intl.DateTimeFormat(\"en-US\", {\n\tmonth: \"short\",\n\tday: \"numeric\",\n\tyear: \"numeric\",\n\ttimeZone: \"UTC\",\n});\nconst monthFormat = new Intl.DateTimeFormat(\"en-US\", {\n\tmonth: \"long\",\n\tyear: \"numeric\",\n\ttimeZone: \"UTC\",\n});\nconst dayFormat = new Intl.DateTimeFormat(\"en-US\", {\n\tdateStyle: \"full\",\n\ttimeZone: \"UTC\",\n});\nconst focusClass =\n\t\"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nfunction localNow() {\n\tconst now = new Date();\n\treturn `${String(now.getFullYear()).padStart(4, \"0\")}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;\n}\n\nfunction validate(value: string) {\n\tif (!/^\\d{4}-\\d{2}-\\d{2}T(?:[01]\\d|2[0-3]):[0-5]\\d$/.test(value))\n\t\tthrow new Error(\n\t\t\t\"DateTimePicker expects local time in YYYY-MM-DDTHH:mm format.\",\n\t\t);\n\tparse(value.slice(0, 10));\n\treturn value;\n}\n\nfunction timeLabel(value: string) {\n\tconst hour = Number(value.slice(11, 13));\n\treturn `${pad(hour % 12 || 12)}:${value.slice(14)} ${hour < 12 ? \"AM\" : \"PM\"}`;\n}\n\n/** Calendar and time-wheel popover with an explicit draft/confirm boundary. */\nexport function DateTimePicker({\n\tvalue: controlledValue,\n\tdefaultValue = \"\",\n\tonValueChange,\n\tlabel = \"Choose date and time\",\n\tdisabled = false,\n\tname,\n\tclassName,\n\tclassNames,\n}: DateTimePickerProps) {\n\tconst [internal, setInternal] = useState(defaultValue);\n\tconst value = controlledValue ?? internal;\n\tif (value) validate(value);\n\tconst [open, setOpen] = useState(false);\n\tconst [draft, setDraft] = useState(\"\");\n\tconst [session, setSession] = useState(0);\n\tconst trigger = useRef<HTMLButtonElement>(null);\n\tconst changeOpen = (next: boolean) => {\n\t\tif (disabled) return;\n\t\tif (next) {\n\t\t\tsetDraft(value || localNow());\n\t\t\tsetSession((current) => current + 1);\n\t\t}\n\t\tsetOpen(next);\n\t};\n\n\treturn (\n\t\t<MorphPopover\n\t\t\topen={open && !disabled}\n\t\t\tonOpenChange={changeOpen}\n\t\t\tclassName={cn(\"max-w-full\", className)}\n\t\t>\n\t\t\t{name && (\n\t\t\t\t<input type=\"hidden\" name={name} value={value} disabled={disabled} />\n\t\t\t)}\n\t\t\t<MorphPopoverTrigger>\n\t\t\t\t<Button\n\t\t\t\t\tref={trigger}\n\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\taria-label={`${label}: ${value ? `${dateFormat.format(parse(value.slice(0, 10)))}, ${timeLabel(value)}` : \"not selected\"}`}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"h-9 max-w-full gap-2 rounded-xl bg-background px-3 text-[13px] font-medium\",\n\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\tclassNames?.trigger,\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<CalendarDays aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n\t\t\t\t\t{value ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span className=\"whitespace-nowrap\">\n\t\t\t\t\t\t\t\t{dateFormat.format(parse(value.slice(0, 10)))}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"h-4 w-px bg-border\" />\n\t\t\t\t\t\t\t<Clock3\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\tclassName=\"hidden size-4 shrink-0 min-[400px]:block\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"whitespace-nowrap tabular-nums\">\n\t\t\t\t\t\t\t\t{timeLabel(value)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<span className=\"text-muted-foreground\">{label}</span>\n\t\t\t\t\t)}\n\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ml-1 size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none\",\n\t\t\t\t\t\t\topen && \"rotate-180\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t</Button>\n\t\t\t</MorphPopoverTrigger>\n\t\t\t<MorphPopoverContent\n\t\t\t\talign=\"start\"\n\t\t\t\tradius={16}\n\t\t\t\tsideOffset={8}\n\t\t\t\tclassName={cn(\"w-[480px] max-w-[calc(100vw-1.5rem)]\", classNames?.content)}\n\t\t\t\tonOpenAutoFocus={(content) =>\n\t\t\t\t\tcontent\n\t\t\t\t\t\t.querySelector<HTMLElement>('[data-calendar-day][tabindex=\"0\"]')\n\t\t\t\t\t\t?.focus({ preventScroll: true })\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{draft && (\n\t\t\t\t\t<DateTimePanel\n\t\t\t\t\t\tkey={session}\n\t\t\t\t\t\tinitialValue={draft}\n\t\t\t\t\t\tclassNames={classNames}\n\t\t\t\t\t\tonConfirm={(next) => {\n\t\t\t\t\t\t\tif (controlledValue === undefined) setInternal(next);\n\t\t\t\t\t\t\tonValueChange?.(next);\n\t\t\t\t\t\t\tsetOpen(false);\n\t\t\t\t\t\t\ttrigger.current?.focus({ preventScroll: true });\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</MorphPopoverContent>\n\t\t</MorphPopover>\n\t);\n}\n\nfunction DateTimePanel({\n\tinitialValue,\n\tonConfirm,\n\tclassNames,\n}: {\n\tinitialValue: string;\n\tonConfirm: (value: string) => void;\n\tclassNames?: DateTimePickerClassNames;\n}) {\n\tconst [draft, setDraft] = useState(initialValue);\n\tconst [cursor, setCursor] = useState(initialValue.slice(0, 10));\n\tconst reduce = useReducedMotion() ?? false;\n\tconst id = useId();\n\tconst calendar = useRef<HTMLTableElement>(null);\n\tconst moveFocus = useRef(true);\n\tconst date = draft.slice(0, 10);\n\tconst month = monthOf(cursor);\n\tconst hour = Number(draft.slice(11, 13));\n\tconst period = hour < 12 ? \"AM\" : \"PM\";\n\tconst first = parse(month);\n\tconst start = first - ((new Date(first).getUTCDay() + 6) % 7) * DAY;\n\tconst daysInView =\n\t\tMath.ceil((parse(shiftMonth(month, 1)) - start) / DAY / 7) * 7;\n\tconst days = Array.from({ length: daysInView }, (_, index) =>\n\t\tiso(start + index * DAY),\n\t);\n\n\tuseLayoutEffect(() => {\n\t\tif (!moveFocus.current) return;\n\t\tcalendar.current\n\t\t\t?.querySelector<HTMLElement>(`[data-calendar-day=\"${cursor}\"]`)\n\t\t\t?.focus({ preventScroll: true });\n\t\tmoveFocus.current = false;\n\t}, [cursor]);\n\n\tconst setHour = (next: number) =>\n\t\tsetDraft(\n\t\t\t(current) => `${current.slice(0, 11)}${pad(next)}${current.slice(13)}`,\n\t\t);\n\tconst navigate = (event: KeyboardEvent<HTMLButtonElement>) => {\n\t\tconst offsets: Record<string, number> = {\n\t\t\tArrowLeft: -1,\n\t\t\tArrowRight: 1,\n\t\t\tArrowUp: -7,\n\t\t\tArrowDown: 7,\n\t\t};\n\t\tconst key = event.key;\n\t\tif (\n\t\t\t!(key in offsets) &&\n\t\t\t![\"Home\", \"End\", \"PageUp\", \"PageDown\"].includes(key)\n\t\t)\n\t\t\treturn;\n\t\tevent.preventDefault();\n\t\tmoveFocus.current = true;\n\t\tsetCursor((current) => {\n\t\t\tif (key === \"PageUp\" || key === \"PageDown\")\n\t\t\t\treturn shiftMonth(\n\t\t\t\t\tcurrent,\n\t\t\t\t\t(key === \"PageUp\" ? -1 : 1) * (event.shiftKey ? 12 : 1),\n\t\t\t\t);\n\t\t\tconst weekday = (new Date(parse(current)).getUTCDay() + 6) % 7;\n\t\t\treturn iso(\n\t\t\t\tparse(current) +\n\t\t\t\t\t(key === \"Home\"\n\t\t\t\t\t\t? -weekday\n\t\t\t\t\t\t: key === \"End\"\n\t\t\t\t\t\t\t? 6 - weekday\n\t\t\t\t\t\t\t: offsets[key]) *\n\t\t\t\t\t\tDAY,\n\t\t\t);\n\t\t});\n\t};\n\n\treturn (\n\t\t<div>\n\t\t\t<div className=\"grid grid-cols-1 min-[520px]:grid-cols-[1fr_160px]\">\n\t\t\t\t<section aria-label=\"Calendar\" className={cn(\"min-w-0 p-3.5\", classNames?.calendar)}>\n\t\t\t\t\t<div className=\"mb-2 flex items-center justify-between gap-2\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\taria-label=\"Previous month\"\n\t\t\t\t\t\t\tclassName={focusClass}\n\t\t\t\t\t\t\tonClick={() => setCursor((current) => shiftMonth(current, -1))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<h3\n\t\t\t\t\t\t\tid={`${id}-month`}\n\t\t\t\t\t\t\taria-live=\"polite\"\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{monthFormat.format(first)}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\taria-label=\"Next month\"\n\t\t\t\t\t\t\tclassName={focusClass}\n\t\t\t\t\t\t\tonClick={() => setCursor((current) => shiftMonth(current, 1))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<p id={`${id}-help`} className=\"sr-only\">\n\t\t\t\t\t\tUse arrow keys for days, Home and End for the week, Page Up and Page\n\t\t\t\t\t\tDown for months, or hold Shift for years. Enter selects a date.\n\t\t\t\t\t\tConfirm saves the date and time; Escape discards changes.\n\t\t\t\t\t</p>\n\t\t\t\t\t<motion.table\n\t\t\t\t\t\tkey={month}\n\t\t\t\t\t\tref={calendar}\n\t\t\t\t\t\trole=\"grid\"\n\t\t\t\t\t\taria-labelledby={`${id}-month`}\n\t\t\t\t\t\taria-describedby={`${id}-help`}\n\t\t\t\t\t\tclassName=\"w-full table-fixed\"\n\t\t\t\t\t\tinitial={{ opacity: reduce ? 1 : 0 }}\n\t\t\t\t\t\tanimate={{ opacity: 1 }}\n\t\t\t\t\t\ttransition={{ duration: 0.15, ease: EASE_OUT }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t{[\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"].map((day) => (\n\t\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\t\tkey={day}\n\t\t\t\t\t\t\t\t\t\tscope=\"col\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"pb-3 text-center text-xs font-normal text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{day}\n\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t{Array.from({ length: daysInView / 7 }, (_, week) => (\n\t\t\t\t\t\t\t\t<tr key={days[week * 7]}>\n\t\t\t\t\t\t\t\t\t{days.slice(week * 7, week * 7 + 7).map((day) => (\n\t\t\t\t\t\t\t\t\t\t// biome-ignore lint/a11y/useFocusableInteractive: Calendar cells delegate focus to the contained day button.\n\t\t\t\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\t\t\t\t// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Gridcell selection semantics belong on the native table cell.\n\t\t\t\t\t\t\t\t\t\t\trole=\"gridcell\"\n\t\t\t\t\t\t\t\t\t\t\tkey={day}\n\t\t\t\t\t\t\t\t\t\t\taria-selected={day === date}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"py-0.5\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\tdata-calendar-day={day}\n\t\t\t\t\t\t\t\t\t\t\t\ttabIndex={day === cursor ? 0 : -1}\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={dayFormat.format(parse(day))}\n\t\t\t\t\t\t\t\t\t\t\t\tonKeyDown={navigate}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tmoveFocus.current = true;\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetCursor(day);\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetDraft((current) => `${day}${current.slice(10)}`);\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\twhileTap={reduce ? undefined : { scale: 0.92 }}\n\t\t\t\t\t\t\t\t\t\t\t\ttransition={SPRING_PRESS}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"relative mx-auto flex size-8 items-center justify-center rounded-xl text-xs tabular-nums transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\t\t\t\t\t\t\t\tday === date\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-foreground font-medium text-background shadow-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"hover:bg-muted\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tday !== date &&\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmonthOf(day) !== month &&\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"text-muted-foreground/40\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassNames?.day,\n\t\t\t\t\t\t\t\t\t\t\t\t\tday === date && classNames?.selectedDay,\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{Number(day.slice(8))}\n\t\t\t\t\t\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</motion.table>\n\t\t\t\t</section>\n\t\t\t\t<section\n\t\t\t\t\taria-label=\"Time\"\n\t\t\t\t\tclassName={cn(\"m-3.5 mt-0 border-t border-border pt-3.5 min-[520px]:ml-0 min-[520px]:mt-3.5 min-[520px]:border-l min-[520px]:border-t-0 min-[520px]:pl-3.5 min-[520px]:pt-0\", classNames?.time)}\n\t\t\t\t>\n\t\t\t\t\t<h3 className=\"mb-3 text-sm font-medium\">Time</h3>\n\t\t\t\t\t<div className=\"flex items-center gap-1 tabular-nums\">\n\t\t\t\t\t\t{reduce ? (\n\t\t\t\t\t\t\t<label className=\"flex flex-1 flex-col gap-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\tHour\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\taria-label=\"Hour\"\n\t\t\t\t\t\t\t\t\tvalue={pad(hour % 12 || 12)}\n\t\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\t\tsetHour(\n\t\t\t\t\t\t\t\t\t\t\t(Number(event.target.value) % 12) +\n\t\t\t\t\t\t\t\t\t\t\t\t(period === \"PM\" ? 12 : 0),\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"h-10 rounded-lg border border-border bg-background px-2 text-sm text-foreground\",\n\t\t\t\t\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{HOURS.map((value) => (\n\t\t\t\t\t\t\t\t\t\t<option key={value}>{value}</option>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<WheelPicker\n\t\t\t\t\t\t\t\taria-label=\"Hour\"\n\t\t\t\t\t\t\t\toptions={HOURS}\n\t\t\t\t\t\t\t\tvalue={pad(hour % 12 || 12)}\n\t\t\t\t\t\t\t\tonValueChange={(next) =>\n\t\t\t\t\t\t\t\t\tsetHour((Number(next) % 12) + (period === \"PM\" ? 12 : 0))\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\titemHeight={32}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 rounded-lg border-0 bg-transparent text-base\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"text-muted-foreground\">\n\t\t\t\t\t\t\t:\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{reduce ? (\n\t\t\t\t\t\t\t<label className=\"flex flex-1 flex-col gap-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\tMinute\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\taria-label=\"Minute\"\n\t\t\t\t\t\t\t\t\tvalue={draft.slice(14)}\n\t\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\t\tsetDraft(\n\t\t\t\t\t\t\t\t\t\t\t(current) =>\n\t\t\t\t\t\t\t\t\t\t\t\t`${current.slice(0, 14)}${event.target.value}`,\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"h-10 rounded-lg border border-border bg-background px-2 text-sm text-foreground\",\n\t\t\t\t\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{MINUTES.map((value) => (\n\t\t\t\t\t\t\t\t\t\t<option key={value}>{value}</option>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<WheelPicker\n\t\t\t\t\t\t\t\taria-label=\"Minute\"\n\t\t\t\t\t\t\t\toptions={MINUTES}\n\t\t\t\t\t\t\t\tvalue={draft.slice(14)}\n\t\t\t\t\t\t\t\tonValueChange={(next) =>\n\t\t\t\t\t\t\t\t\tsetDraft((current) => `${current.slice(0, 14)}${next}`)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\titemHeight={32}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 rounded-lg border-0 bg-transparent text-base\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<fieldset\n\t\t\t\t\t\taria-label=\"Time period\"\n\t\t\t\t\t\tclassName=\"mt-3 flex rounded-xl border border-border bg-muted/50 p-1\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{[\"AM\", \"PM\"].map((next) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={next}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\taria-pressed={next === period}\n\t\t\t\t\t\t\t\tonClick={() => setHour((hour % 12) + (next === \"PM\" ? 12 : 0))}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"relative flex h-8 flex-1 items-center justify-center rounded-lg text-xs\",\n\t\t\t\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{next === period && (\n\t\t\t\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\tlayoutId={reduce ? undefined : `${id}-period`}\n\t\t\t\t\t\t\t\t\t\ttransition={SPRING_LAYOUT}\n\t\t\t\t\t\t\t\t\t\tclassName=\"absolute inset-0 rounded-lg border border-border bg-background shadow-sm\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<span className=\"relative\">{next}</span>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</fieldset>\n\t\t\t\t</section>\n\t\t\t</div>\n\t\t\t<div className=\"flex items-center justify-between gap-2 border-t border-border px-3.5 py-2.5\">\n\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\tLocal time ·{\" \"}\n\t\t\t\t\t{new Intl.DateTimeFormat(\"en-US\", { timeZoneName: \"shortOffset\" })\n\t\t\t\t\t\t.formatToParts(new Date(draft))\n\t\t\t\t\t\t.find((part) => part.type === \"timeZoneName\")\n\t\t\t\t\t\t?.value.replace(\"GMT\", \"UTC\")}\n\t\t\t\t</span>\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tclassName={cn(\"rounded-lg\", focusClass)}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tconst now = localNow();\n\t\t\t\t\t\t\tsetDraft(now);\n\t\t\t\t\t\t\tsetCursor(now.slice(0, 10));\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\tNow\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-lg bg-foreground px-4 text-background hover:bg-foreground/90\",\n\t\t\t\t\t\t\tfocusClass,\n\t\t\t\t\t\t\tclassNames?.confirmButton,\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonClick={() => onConfirm(draft)}\n\t\t\t\t\t>\n\t\t\t\t\t\tConfirm\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
    },
    {
      "path": "components/motion/button/base.tsx",
      "type": "registry:component",
      "target": "@components/motion/button/base.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-muted/60\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"
    },
    {
      "path": "components/motion/date-range-picker/date-utils.ts",
      "type": "registry:component",
      "target": "@components/motion/date-range-picker/date-utils.ts",
      "content": "import type { DateRange } from \"./types\";\n\nexport const DAY = 86_400_000;\nexport function parse(date: string) {\n  const time = Date.parse(`${date}T00:00:00Z`);\n  if (\n    !/^\\d{4}-\\d{2}-\\d{2}$/.test(date) ||\n    !Number.isFinite(time) ||\n    new Date(time).toISOString().slice(0, 10) !== date\n  )\n    throw new Error(`Invalid calendar date: ${date}. Use YYYY-MM-DD.`);\n  return time;\n}\nexport function iso(time: number) {\n  return new Date(time).toISOString().slice(0, 10);\n}\nexport function monthOf(date: string) {\n  return `${date.slice(0, 7)}-01`;\n}\nexport function shiftMonth(date: string, offset: number) {\n  const day = new Date(parse(date));\n  const month = new Date(parse(monthOf(date)));\n  month.setUTCMonth(month.getUTCMonth() + offset);\n  const end = new Date(month);\n  end.setUTCMonth(end.getUTCMonth() + 1);\n  end.setUTCDate(0);\n  month.setUTCDate(Math.min(day.getUTCDate(), end.getUTCDate()));\n  return iso(month.getTime());\n}\nexport function ordered(a: string, b: string): Required<DateRange> {\n  return a <= b ? { from: a, to: b } : { from: b, to: a };\n}\n"
    },
    {
      "path": "components/motion/popover-morph.tsx",
      "type": "registry:component",
      "target": "@components/motion/popover-morph.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  animate,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child?.props?.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)\n    ?.ref;\n  // Register once per actual ref change, not once per open-state render.\n  const mergedRef = useMemo(\n    () => mergeRefs(childRef, ctx.registerTrigger),\n    [childRef, ctx.registerTrigger],\n  );\n  if (!isValidElement(children)) return children;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergedRef,\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipAt(side: Side, align: Align, radius: number, inset: number) {\n  const top = side === \"bottom\" ? \"0%\" : `${inset}%`;\n  const bottom = side === \"bottom\" ? `${inset}%` : \"0%\";\n  const right = align === \"end\" ? \"0%\" : `${inset}%`;\n  const left = align === \"end\" ? `${inset}%` : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  /** Draw the surface shadow. Default true. */\n  shadow?: boolean;\n  /** Runs after the portalled surface is positioned and visible. */\n  onOpenAutoFocus?: (content: HTMLDivElement) => void;\n  /** Override the panel landmark. Menus pass \"menu\" instead of the dialog default. */\n  role?: \"dialog\" | \"menu\";\n  className?: string;\n}\n\nexport function MorphPopoverContent(props: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const [portalReady, setPortalReady] = useState(false);\n  useEffect(() => setPortalReady(true), []);\n  if (!portalReady) return null;\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open && <MorphPopoverSurface {...props} />}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n// Measurement belongs to the mounted portal session: reopening must not start\n// an entrance at the previous session's coordinates before measuring this one.\nfunction MorphPopoverSurface({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  shadow = true,\n  onOpenAutoFocus,\n  role = \"dialog\",\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [isPresent, safeToRemove] = usePresence();\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    isPresent,\n  );\n\n  const preferredLeft = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  // Larger compositions, such as calendars, use the roomier side if needed.\n  const gutter = 8;\n  const below = layout\n    ? Math.max(0, window.innerHeight - layout.trigger.top - layout.trigger.height - sideOffset - gutter)\n    : 0;\n  const above = layout ? Math.max(0, layout.trigger.top - sideOffset - gutter) : 0;\n  const requestedSpace = side === \"bottom\" ? below : above;\n  const otherSpace = side === \"bottom\" ? above : below;\n  const resolvedSide = layout && layout.content.height > requestedSpace && otherSpace > requestedSpace\n    ? side === \"bottom\" ? \"top\" : \"bottom\"\n    : side;\n  const availableHeight = resolvedSide === \"bottom\" ? below : above;\n  const left = layout\n    ? Math.max(gutter, Math.min(preferredLeft, window.innerWidth - layout.content.width - gutter))\n    : 0;\n  const top = layout\n    ? resolvedSide === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : Math.max(gutter, layout.trigger.top - layout.content.height - sideOffset)\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { scale: 0.96, transition: SPRING_PANEL },\n        show: { scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipAt(resolvedSide, align, radius, 92),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipAt(resolvedSide, align, radius, 0),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n  // Animate the value directly so opacity stays in the inline style throughout\n  // the entrance. A native opacity animation can expose the initial inline 0\n  // for a frame when it finishes, before Motion writes the final value.\n  const opacity = useMotionValue(0);\n  const ready = layout !== null;\n  const focusedOnOpen = useRef(false);\n  useEffect(() => {\n    if (!ready || !isPresent || focusedOnOpen.current || !ctx.contentRef.current) return;\n    focusedOnOpen.current = true;\n    onOpenAutoFocus?.(ctx.contentRef.current);\n  }, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);\n  useEffect(() => {\n    if (!ready) {\n      if (!isPresent) safeToRemove?.();\n      return;\n    }\n    const animation = animate(opacity, isPresent ? 1 : 0, {\n      ...(reduce ? { duration: 0.12 } : SPRING_PANEL),\n      onComplete: () => {\n        if (!isPresent) safeToRemove?.();\n      },\n    });\n    return () => animation.stop();\n  }, [opacity, ready, isPresent, reduce, safeToRemove]);\n\n  return (\n    <motion.div\n      data-morph-popover-portal=\"\"\n      inert={!isPresent}\n      // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n      // clipped shape below (box-shadow would just get clipped away).\n      variants={wrap}\n      initial=\"hidden\"\n      animate={layout ? \"show\" : \"hidden\"}\n      exit=\"hidden\"\n      style={{\n        left,\n        top,\n        opacity,\n        pointerEvents: isPresent ? \"auto\" : \"none\",\n        visibility: layout ? \"visible\" : \"hidden\",\n        transformOrigin: originFor(resolvedSide, align),\n      }}\n      className={cn(\"fixed z-[9999]\", shadow && \"[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={role}\n        aria-labelledby={ctx.triggerId}\n        variants={clip}\n        style={{ borderRadius: radius, maxHeight: layout ? availableHeight : undefined, overflowY: \"auto\" }}\n        className={cn(\n          \"overflow-hidden border border-border bg-background\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n"
    },
    {
      "path": "components/motion/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  useId,\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 id = useId();\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 activeIndex = indexOf(currentValue);\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      aria-activedescendant={options.length ? `${id}-option-${activeIndex}` : undefined}\n      aria-disabled={disabled || undefined}\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      {options.map((option, index) => (\n        <span key={optionValue(option)} id={`${id}-option-${index}`} role=\"option\" tabIndex={-1} aria-selected={index === activeIndex} className=\"sr-only\">\n          {optionLabel(option)}\n        </span>\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/ease.ts",
      "type": "registry:lib",
      "target": "@lib/ease.ts",
      "content": "// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"
    },
    {
      "path": "lib/utils.ts",
      "type": "registry:lib",
      "target": "@lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
    },
    {
      "path": "lib/hooks/use-hover-capable.ts",
      "type": "registry:hook",
      "target": "@lib/hooks/use-hover-capable.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"
    },
    {
      "path": "components/motion/date-range-picker/types.ts",
      "type": "registry:component",
      "target": "@components/motion/date-range-picker/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/** Calendar dates, independent of a browser's timezone: YYYY-MM-DD. */\nexport interface DateRange {\n  from: string;\n  to?: string;\n}\nexport interface DateRangePreset {\n  label: string;\n  value: DateRange;\n}\nexport interface DateRangePickerProps {\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  onValueChange?: (range: DateRange | null) => void;\n  defaultMonth?: string;\n  min?: string;\n  max?: string;\n  isDateDisabled?: (date: string) => boolean;\n  presets?: DateRangePreset[];\n  locale?: string;\n  label?: string;\n  disabled?: boolean;\n  /** Move focus to the active date on mount, for use inside a popover. */\n  autoFocus?: boolean;\n  /** Show the start/end summary above the calendar. Default true. */\n  showSummary?: boolean;\n  className?: string;\n  /** Compose custom controls; omitted children render the inline calendar. */\n  children?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Close an open popover after a complete range or preset. Default false. */\n  closeOnSelect?: boolean;\n}\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": "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 * 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"
    }
  ]
}
