{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar","type":"registry:block","title":"Event Calendar","description":"Month and week event calendar: month cells pack events into lanes so multi-day events become bars across the week row and overflowing days open a \"+N more\" list, while week view places timed events on an hour grid, side by side where they overlap, with a live now line and optional 15-minute drag to reschedule. Periods slide in from the direction you travel, the Month/Week switcher pill glides between views, and day cells support arrow-key navigation.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/event-calendar.tsx","type":"registry:component","target":"@components/motion/event-calendar.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/event-calendar\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type KeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\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\n// ── public types ───────────────────────────────────────────────────────────\n\n/** 1–5 map to the theme's `--chart-N` tokens; any other string is used as a\n * CSS color as-is. */\nexport type CalendarEventColor = 1 | 2 | 3 | 4 | 5 | (string & {});\n\nexport type CalendarEvent = {\n  id: string;\n  title: string;\n  start: Date;\n  /** Exclusive end. An all-day event ending at midnight does not cover the\n   * day that midnight starts. */\n  end: Date;\n  color?: CalendarEventColor;\n  allDay?: boolean;\n};\n\nexport type EventCalendarView = \"month\" | \"week\";\n\nexport type EventCalendarProps = {\n  events: CalendarEvent[];\n  /** Controlled visible period: any day inside the month or week shown. */\n  date?: Date;\n  /** Uncontrolled initial period. Default: today, read after mount. */\n  defaultDate?: Date;\n  onDateChange?: (date: Date) => void;\n  view?: EventCalendarView;\n  defaultView?: EventCalendarView;\n  onViewChange?: (view: EventCalendarView) => void;\n  onEventClick?: (event: CalendarEvent) => void;\n  /** An empty day (month view) or time slot (week view, 30-min precision)\n   * was clicked. */\n  onSlotClick?: (date: Date) => void;\n  /** Enables dragging timed events in week view (mouse and pen) in 15-minute\n   * steps, across days too. The calendar does not move the event itself:\n   * apply `next` to your `events`. */\n  onEventChange?: (event: CalendarEvent, next: { start: Date; end: Date }) => void;\n  /** 0 = Sunday … 6 = Saturday. Default 0. */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** BCP 47 locale for dates and times. Default \"en-US\" — a fixed default\n   * keeps server and client output identical for hydration. */\n  locale?: string;\n  /** Height of the calendar body below the toolbar, in px. Default 520. */\n  height?: number;\n  /** Most event rows per month cell (fewer if the cell is short). An\n   * overflowing day shows \"+N more\". Default 3. */\n  maxEventsPerDay?: number;\n  className?: string;\n  \"aria-label\"?: string;\n};\n\n// ── date helpers (local time) ──────────────────────────────────────────────\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);\nfunction addMonths(d: Date, n: number) {\n  const target = new Date(d.getFullYear(), d.getMonth() + n, 1);\n  const last = new Date(target.getFullYear(), target.getMonth() + 1, 0);\n  target.setDate(Math.min(d.getDate(), last.getDate()));\n  return target;\n}\nconst startOfWeek = (d: Date, weekStartsOn: number) =>\n  addDays(startOfDay(d), -((d.getDay() - weekStartsOn + 7) % 7));\nconst sameDay = (a?: Date | null, b?: Date | null) =>\n  !!a && !!b && startOfDay(a).getTime() === startOfDay(b).getTime();\nconst sameMonth = (a: Date, b: Date) =>\n  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n/** Whole days from a to b; rounding absorbs DST hour shifts. */\nconst diffDays = (a: Date, b: Date) =>\n  Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / 864e5);\nconst dayKey = (d: Date) =>\n  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\n\nconst firstDayOf = (e: CalendarEvent) => startOfDay(e.start);\n/** Last day an event touches (its end is exclusive). */\nconst lastDayOf = (e: CalendarEvent) =>\n  e.end > e.start ? startOfDay(new Date(e.end.getTime() - 1)) : firstDayOf(e);\nconst isMultiDay = (e: CalendarEvent) =>\n  lastDayOf(e).getTime() !== firstDayOf(e).getTime();\n/** Lives in the all-day lane of week view rather than the time grid. */\nconst isAllDayLane = (e: CalendarEvent) =>\n  !!e.allDay || e.end.getTime() - e.start.getTime() >= 864e5;\n\nconst eventColor = (c?: CalendarEventColor) =>\n  typeof c === \"number\" ? `var(--chart-${c})` : (c ?? \"var(--chart-1)\");\nconst tint = (color: string, pct: number) =>\n  `color-mix(in oklab, ${color} ${pct}%, var(--card))`;\n\n// ── layout helpers ─────────────────────────────────────────────────────────\n\ntype Seg = {\n  event: CalendarEvent;\n  c0: number;\n  c1: number;\n  lane: number;\n  bar: boolean;\n};\ntype Piece = Seg & { from: number; to: number };\n\n/** Packs the events touching a 7-day row into lanes; longer spans first so\n * multi-day bars stay on top and straight. */\nfunction layoutRow(rowStart: Date, events: CalendarEvent[]): Seg[] {\n  const rowEnd = addDays(rowStart, 6);\n  const segs: Seg[] = [];\n  for (const event of events) {\n    const f = firstDayOf(event);\n    const l = lastDayOf(event);\n    if (l < rowStart || f > rowEnd) continue;\n    segs.push({\n      event,\n      c0: Math.max(0, diffDays(rowStart, f)),\n      c1: Math.min(6, diffDays(rowStart, l)),\n      lane: 0,\n      bar: !!event.allDay || isMultiDay(event),\n    });\n  }\n  segs.sort(\n    (a, b) =>\n      b.c1 - b.c0 - (a.c1 - a.c0) ||\n      a.c0 - b.c0 ||\n      Number(b.bar) - Number(a.bar) ||\n      a.event.start.getTime() - b.event.start.getTime() ||\n      a.event.id.localeCompare(b.event.id),\n  );\n  const used: boolean[][] = [];\n  for (const s of segs) {\n    let lane = 0;\n    for (;;) {\n      const row = used[lane] ?? [];\n      let free = true;\n      for (let c = s.c0; c <= s.c1; c++) if (row[c]) free = false;\n      if (free) {\n        for (let c = s.c0; c <= s.c1; c++) row[c] = true;\n        used[lane] = row;\n        break;\n      }\n      lane++;\n    }\n    s.lane = lane;\n  }\n  return segs;\n}\n\n/** Splits lane segments into the visible runs per day. A day that needs more\n * lanes than fit gives up its last lane to a \"+N more\" button. */\nfunction fitRow(segs: Seg[], maxLanes: number) {\n  const limit: number[] = [];\n  const hidden: number[] = [];\n  for (let c = 0; c < 7; c++) {\n    const covering = segs.filter((s) => s.c0 <= c && c <= s.c1);\n    const needed = covering.reduce((m, s) => Math.max(m, s.lane + 1), 0);\n    limit[c] = needed <= maxLanes ? maxLanes : maxLanes - 1;\n    hidden[c] = covering.filter((s) => s.lane >= limit[c]).length;\n  }\n  const pieces: Piece[] = [];\n  for (const s of segs) {\n    let from = -1;\n    for (let c = s.c0; c <= s.c1 + 1; c++) {\n      const visible = c <= s.c1 && s.lane < limit[c];\n      if (visible && from < 0) from = c;\n      if (!visible && from >= 0) {\n        pieces.push({ ...s, from, to: c - 1 });\n        from = -1;\n      }\n    }\n  }\n  return { pieces, hidden, limit };\n}\n\ntype Block = {\n  event: CalendarEvent;\n  startMin: number;\n  endMin: number;\n  col: number;\n  cols: number;\n};\n\n/** Timed events of one day, side by side where they overlap. */\nfunction layoutDay(day: Date, events: CalendarEvent[]): Block[] {\n  const dayEnd = addDays(day, 1);\n  const blocks: Block[] = [];\n  for (const event of events) {\n    if (isAllDayLane(event)) continue;\n    if (!(event.start < dayEnd && event.end > day)) continue;\n    const startMin =\n      event.start < day\n        ? 0\n        : event.start.getHours() * 60 + event.start.getMinutes();\n    const endMin =\n      event.end >= dayEnd\n        ? 1440\n        : event.end.getHours() * 60 + event.end.getMinutes();\n    // A zero-length or tiny event still gets a readable 15-minute block.\n    blocks.push({\n      event,\n      startMin,\n      endMin: Math.min(1440, Math.max(endMin, startMin + 15)),\n      col: 0,\n      cols: 1,\n    });\n  }\n  blocks.sort(\n    (a, b) =>\n      a.startMin - b.startMin ||\n      b.endMin - a.endMin ||\n      a.event.id.localeCompare(b.event.id),\n  );\n  let cluster: Block[] = [];\n  let colEnds: number[] = [];\n  let clusterEnd = 0;\n  const close = () => {\n    for (const b of cluster) b.cols = colEnds.length;\n    cluster = [];\n    colEnds = [];\n  };\n  for (const b of blocks) {\n    if (cluster.length && b.startMin >= clusterEnd) close();\n    let col = colEnds.findIndex((end) => end <= b.startMin);\n    if (col < 0) {\n      col = colEnds.length;\n      colEnds.push(b.endMin);\n    } else colEnds[col] = b.endMin;\n    b.col = col;\n    cluster.push(b);\n    clusterEnd = cluster.length === 1 ? b.endMin : Math.max(clusterEnd, b.endMin);\n  }\n  close();\n  return blocks;\n}\n\n// ── constants ──────────────────────────────────────────────────────────────\n\n// Period swap is a short directional slide, as in Calendar; exits are quicker.\nconst PERIOD_ENTER = { duration: 0.26, ease: EASE_OUT } as const;\nconst PERIOD_EXIT = { duration: 0.16, ease: EASE_OUT } as const;\nconst POPOVER_ENTER = { duration: 0.18, ease: EASE_OUT } as const;\nconst POPOVER_EXIT = { duration: 0.12, ease: EASE_OUT } as const;\n\nconst WEEKDAY_ROW = 28; // month view weekday header\nconst DAY_HEAD = 22; // day number strip at the top of a month cell\nconst LANE = 18; // one event row in a month cell / all-day lane\nconst HOUR = 48; // px per hour in week view\nconst SNAP_MIN = 15; // drag step in week view\nconst WEEK_HEAD = 48;\nconst ALL_DAY_LANES = 2;\nconst GUTTER = \"3rem\";\n\ntype MoreState = {\n  day: Date;\n  style: CSSProperties;\n  key: string;\n};\n\n/**\n * Month and week event calendar. Months lay events out in lanes so multi-day\n * events become bars across the week row, with \"+N more\" opening the full day\n * list; weeks place timed events on an hour grid, side by side where they\n * overlap, with a live \"now\" line and optional 15-minute drag to reschedule.\n * Periods slide in from the direction you travel and the view switcher pill\n * glides between Month and Week. Controlled/uncontrolled date and view,\n * arrow-key day navigation, reduced-motion safe.\n */\nexport function EventCalendar({\n  events,\n  date: controlledDate,\n  defaultDate,\n  onDateChange,\n  view: controlledView,\n  defaultView = \"month\",\n  onViewChange,\n  onEventClick,\n  onSlotClick,\n  onEventChange,\n  weekStartsOn = 0,\n  locale = \"en-US\",\n  height = 520,\n  maxEventsPerDay = 3,\n  className,\n  \"aria-label\": ariaLabel = \"Event calendar\",\n}: EventCalendarProps) {\n  const uid = useId();\n  const reduce = !!useReducedMotion();\n\n  // \"Today\" (and the default period) are read after mount: the server's clock\n  // and timezone can disagree with the viewer's and would break hydration.\n  const [today, setToday] = useState<Date | null>(null);\n  const [now, setNow] = useState<Date | null>(null);\n  const [internalDate, setInternalDate] = useState<Date | null>(\n    defaultDate ? startOfDay(defaultDate) : null,\n  );\n  useEffect(() => {\n    const tick = () => {\n      const n = new Date();\n      setNow(n);\n      setToday(startOfDay(n));\n    };\n    tick();\n    setInternalDate((d) => d ?? startOfDay(new Date()));\n    const id = window.setInterval(tick, 60_000);\n    return () => window.clearInterval(id);\n  }, []);\n\n  const cursor = controlledDate ? startOfDay(controlledDate) : internalDate;\n  const [internalView, setInternalView] = useState(defaultView);\n  const view = controlledView ?? internalView;\n  const [direction, setDirection] = useState(0);\n\n  const periodStart = cursor\n    ? view === \"month\"\n      ? startOfMonth(cursor)\n      : startOfWeek(cursor, weekStartsOn)\n    : null;\n  const periodKey = periodStart ? `${view}-${dayKey(periodStart)}` : \"pending\";\n\n  // ── interaction state ──\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [more, setMore] = useState<MoreState | null>(null);\n  const moreOpener = useRef<HTMLElement | null>(null);\n  // Week view keeps the focused hour when arrow keys cross into another week.\n  const slotHour = useRef(9);\n\n  const clickEvent = (event: CalendarEvent) => {\n    setSelectedId(event.id);\n    onEventClick?.(event);\n  };\n\n  const goTo = useCallback(\n    (next: Date) => {\n      const d = startOfDay(next);\n      if (cursor && sameDay(cursor, d)) return;\n      if (cursor) setDirection(Math.sign(d.getTime() - cursor.getTime()));\n      setMore(null);\n      if (!controlledDate) setInternalDate(d);\n      onDateChange?.(d);\n    },\n    [cursor, controlledDate, onDateChange],\n  );\n\n  const step = (dir: 1 | -1) => {\n    if (!cursor) return;\n    goTo(view === \"month\" ? addMonths(cursor, dir) : addDays(cursor, dir * 7));\n  };\n\n  const setView = (v: EventCalendarView) => {\n    if (v === view) return;\n    setDirection(0);\n    setMore(null);\n    if (!controlledView) setInternalView(v);\n    onViewChange?.(v);\n  };\n\n\n  const closeMore = useCallback((restoreFocus: boolean) => {\n    setMore(null);\n    if (restoreFocus) moreOpener.current?.focus();\n  }, []);\n\n  const openMore = (\n    e: ReactMouseEvent<HTMLElement>,\n    day: Date,\n    style: CSSProperties,\n  ) => {\n    const key = dayKey(day);\n    if (more?.key === key) return closeMore(false);\n    moreOpener.current = e.currentTarget;\n    setMore({ day, style, key });\n  };\n\n  // ── labels ──\n  const fmt = useMemo(\n    () => ({\n      month: new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n      range: new Intl.DateTimeFormat(locale, {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      }),\n      day: new Intl.DateTimeFormat(locale, {\n        weekday: \"long\",\n        month: \"long\",\n        day: \"numeric\",\n      }),\n      time: new Intl.DateTimeFormat(locale, {\n        hour: \"numeric\",\n        minute: \"2-digit\",\n      }),\n      hour: new Intl.DateTimeFormat(locale, { hour: \"numeric\" }),\n      weekdayShort: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n      weekdayLong: new Intl.DateTimeFormat(locale, { weekday: \"long\" }),\n    }),\n    [locale],\n  );\n  const weekdays = useMemo(\n    () =>\n      // 2023-01-01 was a Sunday.\n      Array.from({ length: 7 }, (_, i) => {\n        const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));\n        return {\n          short: fmt.weekdayShort.format(d),\n          long: fmt.weekdayLong.format(d),\n        };\n      }),\n    [fmt, weekStartsOn],\n  );\n\n  const title = !periodStart\n    ? \"\"\n    : view === \"month\"\n      ? fmt.month.format(periodStart)\n      : // Not formatRange: its separators differ between ICU builds (server\n        // vs browser), which would break hydration of the title.\n        `${fmt.range.format(periodStart)} – ${fmt.range.format(addDays(periodStart, 6))}`;\n\n  const timeLabel = useCallback(\n    (e: CalendarEvent) =>\n      e.allDay\n        ? \"All day\"\n        : sameDay(e.start, e.end)\n          ? `${fmt.time.format(e.start)} – ${fmt.time.format(e.end)}`\n          : `${fmt.time.format(e.start)} – ${fmt.range.format(e.end)} ${fmt.time.format(e.end)}`,\n    [fmt],\n  );\n  const eventLabel = (e: CalendarEvent) =>\n    `${e.title}, ${e.allDay ? \"all day\" : timeLabel(e)}`;\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 shared: SharedProps = {\n    events,\n    periodStart: periodStart ?? new Date(0),\n    periodKey,\n    cursor: cursor ?? new Date(0),\n    today,\n    now,\n    weekStartsOn,\n    height,\n    maxEventsPerDay,\n    reduce,\n    selectedId,\n    hoveredId,\n    setHoveredId,\n    clickEvent,\n    eventLabel,\n    timeLabel,\n    onSlotClick,\n    goTo,\n    openMore,\n    fmt,\n    weekdays,\n    slotHour,\n  };\n\n  return (\n    <section\n      aria-label={ariaLabel}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border border-border bg-card text-foreground\",\n        className,\n      )}\n    >\n      {/* toolbar */}\n      <div className=\"flex items-center gap-2 border-b border-border px-3 py-2.5\">\n        <button\n          type=\"button\"\n          disabled={!today}\n          onClick={() => today && goTo(today)}\n          className=\"inline-flex h-8 items-center rounded-lg border border-border px-3 text-xs font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\"\n        >\n          Today\n        </button>\n        <div className=\"flex items-center\">\n          <button\n            type=\"button\"\n            aria-label={view === \"month\" ? \"Previous month\" : \"Previous week\"}\n            disabled={!cursor}\n            onClick={() => step(-1)}\n            className={navButton}\n          >\n            <ChevronLeft className=\"h-4 w-4\" />\n          </button>\n          <button\n            type=\"button\"\n            aria-label={view === \"month\" ? \"Next month\" : \"Next week\"}\n            disabled={!cursor}\n            onClick={() => step(1)}\n            className={navButton}\n          >\n            <ChevronRight className=\"h-4 w-4\" />\n          </button>\n        </div>\n        <h2\n          aria-live=\"polite\"\n          className=\"min-w-0 flex-1 truncate text-sm font-semibold tabular-nums\"\n        >\n          {title}\n        </h2>\n        <div className=\"relative inline-flex shrink-0 rounded-lg bg-muted p-0.5\">\n          {([\"month\", \"week\"] as const).map((v) => (\n            <button\n              key={v}\n              type=\"button\"\n              aria-pressed={view === v}\n              onClick={() => setView(v)}\n              className={cn(\n                \"relative h-7 rounded-md px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                view === v\n                  ? \"text-foreground\"\n                  : \"text-muted-foreground hover:text-foreground\",\n              )}\n            >\n              {view === v && (\n                <motion.span\n                  aria-hidden\n                  layoutId={`${uid}-view`}\n                  transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                  className=\"absolute inset-0 rounded-md border border-border bg-card shadow-sm\"\n                />\n              )}\n              <span className=\"relative\">{v === \"month\" ? \"Month\" : \"Week\"}</span>\n            </button>\n          ))}\n        </div>\n      </div>\n\n      {/* body */}\n      <div className=\"relative overflow-hidden\" style={{ height }}>\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n          <motion.div\n            key={periodKey}\n            custom={direction}\n            variants={{\n              enter: (dir: number) => ({\n                opacity: 0,\n                x: reduce ? 0 : dir * 28,\n                filter: reduce || !dir ? \"blur(0px)\" : \"blur(4px)\",\n              }),\n              center: {\n                opacity: 1,\n                x: 0,\n                filter: \"blur(0px)\",\n                transition: PERIOD_ENTER,\n              },\n              exit: (dir: number) => ({\n                opacity: 0,\n                x: reduce ? 0 : dir * -28,\n                filter: reduce || !dir ? \"blur(0px)\" : \"blur(4px)\",\n                transition: PERIOD_EXIT,\n              }),\n            }}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            className=\"absolute inset-0\"\n          >\n            {!periodStart ? null : view === \"month\" ? (\n              <MonthView {...shared} />\n            ) : (\n              <WeekView {...shared} onEventChange={onEventChange} />\n            )}\n          </motion.div>\n        </AnimatePresence>\n\n        <AnimatePresence>\n          {more && (\n            <DayPopover\n              key={more.key}\n              day={more.day}\n              style={more.style}\n              events={events\n                .filter(\n                  (e) => firstDayOf(e) <= more.day && lastDayOf(e) >= more.day,\n                )\n                .sort(\n                  (a, b) =>\n                    Number(!!b.allDay) - Number(!!a.allDay) ||\n                    a.start.getTime() - b.start.getTime(),\n                )}\n              title={fmt.day.format(more.day)}\n              reduce={reduce}\n              timeLabel={timeLabel}\n              eventLabel={eventLabel}\n              onClose={closeMore}\n              onEventClick={(e) => {\n                closeMore(false);\n                clickEvent(e);\n              }}\n            />\n          )}\n        </AnimatePresence>\n      </div>\n    </section>\n  );\n}\n\n// ── shared bits ────────────────────────────────────────────────────────────\n\ntype Formats = {\n  month: Intl.DateTimeFormat;\n  range: Intl.DateTimeFormat;\n  day: Intl.DateTimeFormat;\n  time: Intl.DateTimeFormat;\n  hour: Intl.DateTimeFormat;\n  weekdayShort: Intl.DateTimeFormat;\n  weekdayLong: Intl.DateTimeFormat;\n};\n\ntype SharedProps = {\n  events: CalendarEvent[];\n  periodStart: Date;\n  periodKey: string;\n  cursor: Date;\n  today: Date | null;\n  now: Date | null;\n  weekStartsOn: number;\n  height: number;\n  maxEventsPerDay: number;\n  reduce: boolean;\n  selectedId: string | null;\n  hoveredId: string | null;\n  setHoveredId: (id: string | null) => void;\n  clickEvent: (e: CalendarEvent) => void;\n  eventLabel: (e: CalendarEvent) => string;\n  timeLabel: (e: CalendarEvent) => string;\n  onSlotClick?: (date: Date) => void;\n  goTo: (d: Date) => void;\n  openMore: (\n    e: ReactMouseEvent<HTMLElement>,\n    day: Date,\n    style: CSSProperties,\n  ) => void;\n  fmt: Formats;\n  weekdays: { short: string; long: string }[];\n  slotHour: { current: number };\n};\n\n/** Hover/focus/selection handlers that drive the lift. */\nconst liftHandlers = (\n  id: string,\n  setHoveredId: (id: string | null) => void,\n) => ({\n  onPointerEnter: () => setHoveredId(id),\n  onPointerLeave: () => setHoveredId(null),\n  onFocus: () => setHoveredId(id),\n  onBlur: () => setHoveredId(null),\n});\n\n/** Popover position next to a cell: opens toward the free side. */\nfunction popoverStyle(\n  col: number,\n  row: number,\n  rows: number,\n  topPx?: number,\n): CSSProperties {\n  const style: CSSProperties = { width: \"max(13rem, calc(100% / 7))\" };\n  const right = col > 3;\n  if (right) style.right = `${((6 - col) / 7) * 100}%`;\n  else style.left = `${(col / 7) * 100}%`;\n  const below = row < rows / 2;\n  if (topPx !== undefined) style.top = topPx;\n  else if (below) style.top = `calc(${WEEKDAY_ROW}px + (100% - ${WEEKDAY_ROW}px) * ${row / rows})`;\n  else\n    style.bottom = `calc((100% - ${WEEKDAY_ROW}px) * ${(rows - 1 - row) / rows})`;\n  style.transformOrigin = `${right ? \"right\" : \"left\"} ${topPx !== undefined || below ? \"top\" : \"bottom\"}`;\n  return style;\n}\n\n// ── month view ─────────────────────────────────────────────────────────────\n\nfunction MonthView({\n  events,\n  periodStart,\n  periodKey,\n  cursor,\n  today,\n  weekStartsOn,\n  height,\n  maxEventsPerDay,\n  reduce,\n  selectedId,\n  hoveredId,\n  setHoveredId,\n  clickEvent,\n  eventLabel,\n  onSlotClick,\n  goTo,\n  openMore,\n  fmt,\n  weekdays,\n}: SharedProps) {\n  const gridStart = startOfWeek(periodStart, weekStartsOn);\n  const weeks = Array.from({ length: 6 }, (_, w) => addDays(gridStart, w * 7));\n  const rowHeight = (height - WEEKDAY_ROW) / 6;\n  const maxLanes = Math.max(\n    1,\n    Math.min(maxEventsPerDay, Math.floor((rowHeight - DAY_HEAD - 2) / LANE)),\n  );\n\n  // Roving tab stop: the cursor day if it is in this month, else the 1st.\n  const [focusDay, setFocusDay] = useState(() =>\n    sameMonth(cursor, periodStart) ? cursor : periodStart,\n  );\n  const gridRef = useRef<HTMLDivElement>(null);\n  const shouldFocus = useRef(false);\n  useEffect(() => {\n    if (!shouldFocus.current) return;\n    shouldFocus.current = false;\n    gridRef.current\n      ?.querySelector<HTMLButtonElement>(`[data-date=\"${dayKey(focusDay)}\"]`)\n      ?.focus();\n  }, [focusDay]);\n\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    if (!(e.target as HTMLElement).dataset.date) return;\n    const base = focusDay;\n    const col = (base.getDay() - weekStartsOn + 7) % 7;\n    const next =\n      e.key === \"ArrowLeft\"\n        ? addDays(base, -1)\n        : e.key === \"ArrowRight\"\n          ? addDays(base, 1)\n          : e.key === \"ArrowUp\"\n            ? addDays(base, -7)\n            : e.key === \"ArrowDown\"\n              ? addDays(base, 7)\n              : e.key === \"Home\"\n                ? addDays(base, -col)\n                : e.key === \"End\"\n                  ? addDays(base, 6 - col)\n                  : e.key === \"PageUp\"\n                    ? addMonths(base, -1)\n                    : e.key === \"PageDown\"\n                      ? addMonths(base, 1)\n                      : null;\n    if (!next) return;\n    e.preventDefault();\n    if (!sameMonth(next, periodStart)) {\n      // The next grid mounts focused on that day (see the initial state).\n      goTo(next);\n      return;\n    }\n    shouldFocus.current = true;\n    setFocusDay(next);\n  };\n\n  // A month change by keyboard remounts this view; focus the carried day.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount.\n  useEffect(() => {\n    if (\n      document.activeElement &&\n      gridRef.current &&\n      !gridRef.current.contains(document.activeElement) &&\n      document.activeElement.closest(\"[data-event-calendar-grid]\")\n    ) {\n      gridRef.current\n        .querySelector<HTMLButtonElement>(`[data-date=\"${dayKey(focusDay)}\"]`)\n        ?.focus();\n    }\n  }, []);\n\n  return (\n    <div\n      ref={gridRef}\n      data-event-calendar-grid\n      data-period={periodKey}\n      className=\"flex h-full flex-col\"\n    >\n      <div\n        className=\"grid shrink-0 grid-cols-7 border-b border-border\"\n        style={{ height: WEEKDAY_ROW }}\n      >\n        {weekdays.map((w) => (\n          <abbr\n            key={w.long}\n            title={w.long}\n            className=\"flex items-center px-2 text-[11px] font-medium text-muted-foreground no-underline\"\n          >\n            {w.short}\n          </abbr>\n        ))}\n      </div>\n      <div className=\"grid min-h-0 flex-1 grid-rows-6\">\n        {weeks.map((rowStart, r) => {\n          const segs = layoutRow(rowStart, events);\n          const { pieces, hidden, limit } = fitRow(segs, maxLanes);\n          return (\n            <div\n              key={dayKey(rowStart)}\n              className=\"relative grid grid-cols-7 border-b border-border last:border-b-0\"\n            >\n              {Array.from({ length: 7 }, (_, c) => {\n                const d = addDays(rowStart, c);\n                const outside = !sameMonth(d, periodStart);\n                const isToday = sameDay(d, today);\n                const count = segs.filter(\n                  (s) => s.c0 <= c && c <= s.c1,\n                ).length;\n                return (\n                  <button\n                    key={dayKey(d)}\n                    type=\"button\"\n                    data-date={dayKey(d)}\n                    onKeyDown={onKeyDown}\n                    tabIndex={sameDay(d, focusDay) ? 0 : -1}\n                    aria-label={`${fmt.day.format(d)}${count ? `, ${count} event${count > 1 ? \"s\" : \"\"}` : \"\"}`}\n                    aria-current={isToday ? \"date\" : undefined}\n                    onFocus={() => setFocusDay(d)}\n                    onClick={() => {\n                      setFocusDay(d);\n                      onSlotClick?.(d);\n                    }}\n                    className={cn(\n                      \"flex flex-col items-start border-l border-border px-1 pt-1 text-left outline-none transition-colors first:border-l-0 hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n                      outside && \"bg-muted/30\",\n                    )}\n                  >\n                    <span\n                      className={cn(\n                        \"inline-flex size-5 items-center justify-center rounded-full text-[11px] tabular-nums\",\n                        isToday\n                          ? \"bg-primary font-semibold text-primary-foreground\"\n                          : outside\n                            ? \"text-muted-foreground/60\"\n                            : \"text-foreground\",\n                      )}\n                    >\n                      {d.getDate()}\n                    </span>\n                  </button>\n                );\n              })}\n\n              {pieces.map((p) => {\n                const color = eventColor(p.event.color);\n                const lifted =\n                  hoveredId === p.event.id || selectedId === p.event.id;\n                const left = p.from;\n                const span = p.to - p.from + 1;\n                const roundL = p.from === p.c0 && firstDayOf(p.event) >= rowStart;\n                const roundR =\n                  p.to === p.c1 && lastDayOf(p.event) <= addDays(rowStart, 6);\n                return (\n                  <motion.button\n                    key={`${p.event.id}:${p.from}`}\n                    type=\"button\"\n                    aria-label={eventLabel(p.event)}\n                    layout=\"position\"\n                    initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                    animate={{ opacity: 1, y: lifted && !reduce ? -1.5 : 0 }}\n                    transition={reduce ? { duration: 0.15 } : SPRING_PRESS}\n                    onClick={() => clickEvent(p.event)}\n                    {...liftHandlers(p.event.id, setHoveredId)}\n                    className={cn(\n                      \"absolute z-10 flex items-center gap-1 overflow-hidden px-1.5 text-left text-[11px] leading-none outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring\",\n                      p.bar ? \"font-medium\" : \"rounded-md hover:bg-muted\",\n                      p.bar && roundL && \"rounded-l-md\",\n                      p.bar && roundR && \"rounded-r-md\",\n                      lifted && \"shadow-md\",\n                      selectedId === p.event.id && \"ring-1 ring-foreground/20\",\n                    )}\n                    style={{\n                      left: `calc(${(left / 7) * 100}% + ${roundL || !p.bar ? 3 : 0}px)`,\n                      width: `calc(${(span / 7) * 100}% - ${(roundL || !p.bar ? 3 : 0) + (roundR || !p.bar ? 3 : 0)}px)`,\n                      top: DAY_HEAD + p.lane * LANE,\n                      height: LANE - 2,\n                      background: p.bar ? tint(color, 22) : undefined,\n                    }}\n                  >\n                    {p.bar ? (\n                      roundL && (\n                        <span\n                          aria-hidden\n                          className=\"h-2.5 w-0.5 shrink-0 rounded-full\"\n                          style={{ background: color }}\n                        />\n                      )\n                    ) : (\n                      <span\n                        aria-hidden\n                        className=\"size-1.5 shrink-0 rounded-full\"\n                        style={{ background: color }}\n                      />\n                    )}\n                    {!p.bar && (\n                      <span className=\"shrink-0 text-muted-foreground tabular-nums\">\n                        {fmt.time\n                          .format(p.event.start)\n                          .replace(/:00(?=\\s|$)/, \"\")}\n                      </span>\n                    )}\n                    <span className=\"truncate\">{p.event.title}</span>\n                  </motion.button>\n                );\n              })}\n\n              {hidden.map((n, c) =>\n                n > 0 ? (\n                  <button\n                    // biome-ignore lint/suspicious/noArrayIndexKey: one slot per weekday column.\n                    key={c}\n                    type=\"button\"\n                    data-more-trigger\n                    aria-label={`${n} more event${n > 1 ? \"s\" : \"\"} on ${fmt.day.format(addDays(rowStart, c))}`}\n                    onClick={(e) =>\n                      openMore(e, addDays(rowStart, c), popoverStyle(c, r, 6))\n                    }\n                    className=\"absolute z-10 rounded-md px-1.5 text-left text-[11px] font-medium leading-none text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                    style={{\n                      left: `calc(${(c / 7) * 100}% + 3px)`,\n                      width: \"calc(100% / 7 - 6px)\",\n                      top: DAY_HEAD + limit[c] * LANE,\n                      height: LANE - 2,\n                    }}\n                  >\n                    +{n} more\n                  </button>\n                ) : null,\n              )}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// ── week view ──────────────────────────────────────────────────────────────\n\ntype DragState = { id: string; steps: number; days: number; colWidth: number };\n\nfunction WeekView({\n  events,\n  periodStart,\n  cursor,\n  today,\n  now,\n  reduce,\n  selectedId,\n  hoveredId,\n  setHoveredId,\n  clickEvent,\n  eventLabel,\n  timeLabel,\n  onSlotClick,\n  goTo,\n  openMore,\n  fmt,\n  weekdays,\n  slotHour,\n  onEventChange,\n}: SharedProps & {\n  onEventChange?: EventCalendarProps[\"onEventChange\"];\n}) {\n  const days = Array.from({ length: 7 }, (_, i) => addDays(periodStart, i));\n  const segs = layoutRow(periodStart, events.filter(isAllDayLane));\n  const { pieces, hidden, limit } = fitRow(segs, ALL_DAY_LANES);\n  const laneCount = Math.max(\n    1,\n    Math.min(\n      ALL_DAY_LANES,\n      segs.reduce((m, s) => Math.max(m, s.lane + 1), 0),\n    ),\n  );\n  const allDayHeight = laneCount * LANE + 6;\n  const blocksByDay = days.map((d) => layoutDay(d, events));\n\n  // Keep the scroll offset across weeks; the first visit starts at 8:00.\n  const scrollTop = useRef(8 * HOUR);\n  const scrollRef = useCallback((el: HTMLDivElement | null) => {\n    if (el) el.scrollTop = scrollTop.current;\n  }, []);\n\n  // Roving tab stop over the hour slots.\n  const [focusSlot, setFocusSlot] = useState(() => ({\n    day: Math.max(0, Math.min(6, diffDays(periodStart, cursor))),\n    hour: slotHour.current,\n  }));\n  const columnsRef = useRef<HTMLDivElement>(null);\n  const shouldFocus = useRef(false);\n  useEffect(() => {\n    slotHour.current = focusSlot.hour;\n  }, [focusSlot, slotHour]);\n  // A week change by keyboard remounts this view; focus the carried slot.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount.\n  useEffect(() => {\n    const active = document.activeElement;\n    if (\n      active &&\n      columnsRef.current &&\n      !columnsRef.current.contains(active) &&\n      active.closest(\"[data-event-calendar-grid]\")\n    ) {\n      columnsRef.current\n        .querySelector<HTMLButtonElement>(\n          `[data-slot=\"${focusSlot.day}-${focusSlot.hour}\"]`,\n        )\n        ?.focus();\n    }\n  }, []);\n  useEffect(() => {\n    if (!shouldFocus.current) return;\n    shouldFocus.current = false;\n    columnsRef.current\n      ?.querySelector<HTMLButtonElement>(\n        `[data-slot=\"${focusSlot.day}-${focusSlot.hour}\"]`,\n      )\n      ?.focus();\n  }, [focusSlot]);\n\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    if (!(e.target as HTMLElement).dataset.slot) return;\n    const { day, hour } = focusSlot;\n    let next = { day, hour };\n    if (e.key === \"ArrowUp\") next = { day, hour: Math.max(0, hour - 1) };\n    else if (e.key === \"ArrowDown\") next = { day, hour: Math.min(23, hour + 1) };\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n      const nd = day + (e.key === \"ArrowLeft\" ? -1 : 1);\n      if (nd < 0 || nd > 6) {\n        e.preventDefault();\n        goTo(addDays(periodStart, nd));\n        return;\n      }\n      next = { day: nd, hour };\n    } else return;\n    e.preventDefault();\n    shouldFocus.current = true;\n    setFocusSlot(next);\n  };\n\n  // ── drag to reschedule (mouse/pen; touch keeps scrolling the grid) ──\n  const [drag, setDrag] = useState<DragState | null>(null);\n  const [settlingId, setSettlingId] = useState<string | null>(null);\n  const dragRef = useRef<{\n    id: string;\n    x0: number;\n    y0: number;\n    colWidth: number;\n    moved: boolean;\n    steps: number;\n    days: number;\n  } | null>(null);\n  useEffect(() => {\n    if (!settlingId) return;\n    const raf = requestAnimationFrame(() => setSettlingId(null));\n    return () => cancelAnimationFrame(raf);\n  }, [settlingId]);\n\n  const suppressNextClick = useRef(false);\n  const onDragStart = (e: ReactPointerEvent<HTMLElement>, ev: CalendarEvent) => {\n    if (!onEventChange || e.pointerType === \"touch\" || e.button !== 0) return;\n    const width = columnsRef.current?.getBoundingClientRect().width ?? 700;\n    dragRef.current = {\n      id: ev.id,\n      x0: e.clientX,\n      y0: e.clientY,\n      colWidth: width / 7,\n      moved: false,\n      steps: 0,\n      days: 0,\n    };\n    e.currentTarget.setPointerCapture(e.pointerId);\n  };\n  const onDragMove = (e: ReactPointerEvent<HTMLElement>) => {\n    const d = dragRef.current;\n    if (!d) return;\n    const dx = e.clientX - d.x0;\n    const dy = e.clientY - d.y0;\n    if (!d.moved && Math.hypot(dx, dy) < 4) return;\n    d.moved = true;\n    const steps = Math.round(dy / ((HOUR * SNAP_MIN) / 60));\n    const dd = Math.round(dx / d.colWidth);\n    if (steps !== d.steps || dd !== d.days || !drag) {\n      d.steps = steps;\n      d.days = dd;\n      setDrag({ id: d.id, steps, days: dd, colWidth: d.colWidth });\n    }\n  };\n  const onDragEnd = (ev: CalendarEvent, cancelled = false) => {\n    const d = dragRef.current;\n    dragRef.current = null;\n    if (!d) return;\n    if (d.moved) {\n      // Swallow the click that ends the drag; cleared next tick in case the\n      // event remounts in another column and never receives it.\n      suppressNextClick.current = true;\n      window.setTimeout(() => {\n        suppressNextClick.current = false;\n      }, 0);\n    }\n    setDrag(null);\n    if (!d.moved || cancelled || (d.steps === 0 && d.days === 0)) return;\n    const shift = (t: Date) => {\n      const n = new Date(t);\n      n.setDate(n.getDate() + d.days);\n      n.setMinutes(n.getMinutes() + d.steps * SNAP_MIN);\n      return n;\n    };\n    setSettlingId(ev.id);\n    onEventChange?.(ev, { start: shift(ev.start), end: shift(ev.end) });\n  };\n\n  const nowMin = now ? now.getHours() * 60 + now.getMinutes() : 0;\n  const nowCol = now ? days.findIndex((d) => sameDay(d, now)) : -1;\n\n  const rowClass =\n    \"grid shrink-0 overflow-hidden [scrollbar-gutter:stable] [scrollbar-width:thin]\";\n  const gridCols = { gridTemplateColumns: `${GUTTER} repeat(7, minmax(0, 1fr))` };\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      {/* day headers */}\n      <div\n        className={cn(rowClass, \"border-b border-border\")}\n        style={{ ...gridCols, height: WEEK_HEAD }}\n      >\n        <span aria-hidden />\n        {days.map((d, i) => {\n          const isToday = sameDay(d, today);\n          return (\n            <button\n              key={dayKey(d)}\n              type=\"button\"\n              aria-label={fmt.day.format(d)}\n              aria-current={isToday ? \"date\" : undefined}\n              onClick={() => onSlotClick?.(d)}\n              className=\"flex flex-col items-center justify-center gap-0.5 border-l border-border outline-none transition-colors hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n            >\n              <span className=\"text-[10px] font-medium uppercase tracking-wide text-muted-foreground\">\n                {weekdays[i].short}\n              </span>\n              <span\n                className={cn(\n                  \"inline-flex size-6 items-center justify-center rounded-full text-xs font-semibold tabular-nums\",\n                  isToday && \"bg-primary text-primary-foreground\",\n                )}\n              >\n                {d.getDate()}\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      {/* all-day lane */}\n      <div\n        className={cn(rowClass, \"relative border-b border-border\")}\n        style={{ ...gridCols, height: allDayHeight }}\n      >\n        <span className=\"flex items-start justify-end pr-1.5 pt-1.5 text-[10px] text-muted-foreground\">\n          all-day\n        </span>\n        {days.map((d) => (\n          <span key={dayKey(d)} aria-hidden className=\"border-l border-border\" />\n        ))}\n        <div\n          className=\"pointer-events-none absolute inset-y-0 right-0\"\n          style={{ left: GUTTER }}\n        >\n          {pieces.map((p) => {\n            const color = eventColor(p.event.color);\n            const lifted = hoveredId === p.event.id || selectedId === p.event.id;\n            const roundL = p.from === p.c0 && firstDayOf(p.event) >= periodStart;\n            const roundR =\n              p.to === p.c1 && lastDayOf(p.event) <= addDays(periodStart, 6);\n            return (\n              <motion.button\n                key={`${p.event.id}:${p.from}`}\n                type=\"button\"\n                aria-label={eventLabel(p.event)}\n                layout=\"position\"\n                initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                animate={{ opacity: 1, y: lifted && !reduce ? -1.5 : 0 }}\n                transition={reduce ? { duration: 0.15 } : SPRING_PRESS}\n                onClick={() => clickEvent(p.event)}\n                {...liftHandlers(p.event.id, setHoveredId)}\n                className={cn(\n                  \"pointer-events-auto absolute flex items-center gap-1 overflow-hidden px-1.5 text-left text-[11px] font-medium leading-none outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring\",\n                  roundL && \"rounded-l-md\",\n                  roundR && \"rounded-r-md\",\n                  lifted && \"shadow-md\",\n                  selectedId === p.event.id && \"ring-1 ring-foreground/20\",\n                )}\n                style={{\n                  left: `calc(${(p.from / 7) * 100}% + ${roundL ? 3 : 0}px)`,\n                  width: `calc(${((p.to - p.from + 1) / 7) * 100}% - ${(roundL ? 3 : 0) + (roundR ? 3 : 0)}px)`,\n                  top: 3 + p.lane * LANE,\n                  height: LANE - 2,\n                  background: tint(color, 22),\n                }}\n              >\n                {roundL && (\n                  <span\n                    aria-hidden\n                    className=\"h-2.5 w-0.5 shrink-0 rounded-full\"\n                    style={{ background: color }}\n                  />\n                )}\n                <span className=\"truncate\">{p.event.title}</span>\n              </motion.button>\n            );\n          })}\n          {hidden.map((n, c) =>\n            n > 0 ? (\n              <button\n                // biome-ignore lint/suspicious/noArrayIndexKey: one slot per weekday column.\n                key={c}\n                type=\"button\"\n                data-more-trigger\n                aria-label={`${n} more all-day event${n > 1 ? \"s\" : \"\"} on ${fmt.day.format(days[c])}`}\n                onClick={(e) =>\n                  openMore(\n                    e,\n                    days[c],\n                    popoverStyle(c, 0, 1, WEEK_HEAD + allDayHeight),\n                  )\n                }\n                className=\"pointer-events-auto absolute rounded-md px-1.5 text-left text-[11px] font-medium leading-none text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                style={{\n                  left: `calc(${(c / 7) * 100}% + 3px)`,\n                  width: \"calc(100% / 7 - 6px)\",\n                  top: 3 + limit[c] * LANE,\n                  height: LANE - 2,\n                }}\n              >\n                +{n} more\n              </button>\n            ) : null,\n          )}\n        </div>\n      </div>\n\n      {/* time grid */}\n      <motion.div\n        ref={scrollRef}\n        layoutScroll\n        onScroll={(e) => {\n          scrollTop.current = e.currentTarget.scrollTop;\n        }}\n        className=\"min-h-0 flex-1 overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]\"\n      >\n        <div className=\"relative grid\" style={{ ...gridCols, height: 24 * HOUR }}>\n          <div aria-hidden className=\"relative\">\n            {Array.from({ length: 23 }, (_, h) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: hour labels are positional.\n                key={h}\n                className=\"absolute right-1.5 -translate-y-1/2 text-[10px] tabular-nums text-muted-foreground\"\n                style={{ top: (h + 1) * HOUR }}\n              >\n                {fmt.hour.format(new Date(2023, 0, 1, h + 1))}\n              </span>\n            ))}\n          </div>\n          <div\n            ref={columnsRef}\n            data-event-calendar-grid\n            className=\"relative col-span-7 grid grid-cols-7\"\n          >\n            {days.map((d, i) => (\n              <div key={dayKey(d)} className=\"relative border-l border-border\">\n                {Array.from({ length: 24 }, (_, h) => (\n                  <button\n                    // biome-ignore lint/suspicious/noArrayIndexKey: hour slots are positional.\n                    key={h}\n                    type=\"button\"\n                    data-slot={`${i}-${h}`}\n                    onKeyDown={onKeyDown}\n                    tabIndex={focusSlot.day === i && focusSlot.hour === h ? 0 : -1}\n                    aria-label={`${fmt.day.format(d)}, ${fmt.time.format(new Date(2023, 0, 1, h))}`}\n                    onFocus={() => setFocusSlot({ day: i, hour: h })}\n                    onClick={(e) => {\n                      const rect = e.currentTarget.getBoundingClientRect();\n                      // Keyboard clicks report no pointer; they pick the hour.\n                      const half =\n                        e.detail > 0 && e.clientY - rect.top > rect.height / 2;\n                      onSlotClick?.(\n                        new Date(\n                          d.getFullYear(),\n                          d.getMonth(),\n                          d.getDate(),\n                          h,\n                          half ? 30 : 0,\n                        ),\n                      );\n                    }}\n                    className={cn(\n                      \"absolute inset-x-0 outline-none transition-colors hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n                      h > 0 && \"border-t border-border/60\",\n                    )}\n                    style={{ top: h * HOUR, height: HOUR }}\n                  />\n                ))}\n\n                {blocksByDay[i].map((b) => {\n                  const ev = b.event;\n                  const color = eventColor(ev.color);\n                  const dragging = drag?.id === ev.id;\n                  const lifted =\n                    dragging || hoveredId === ev.id || selectedId === ev.id;\n                  const top = (b.startMin / 60) * HOUR;\n                  const h = Math.max(\n                    ((b.endMin - b.startMin) / 60) * HOUR - 2,\n                    14,\n                  );\n                  const compact = h < 34;\n                  const dx = dragging ? drag.days * drag.colWidth : 0;\n                  const dy = dragging ? (drag.steps * HOUR * SNAP_MIN) / 60 : 0;\n                  return (\n                    <motion.button\n                      key={`${ev.id}:${dayKey(d)}`}\n                      type=\"button\"\n                      aria-label={eventLabel(ev)}\n                      layout={settlingId === ev.id || dragging ? false : \"position\"}\n                      initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}\n                      animate={{\n                        opacity: 1,\n                        scale: 1,\n                        x: dx,\n                        y: dy + (lifted && !reduce ? -1.5 : 0),\n                      }}\n                      transition={\n                        reduce || settlingId === ev.id\n                          ? { duration: reduce ? 0.15 : 0 }\n                          : SPRING_PRESS\n                      }\n                      onClick={() => {\n                        if (suppressNextClick.current) {\n                          suppressNextClick.current = false;\n                          return;\n                        }\n                        clickEvent(ev);\n                      }}\n                      onPointerDown={(e) => onDragStart(e, ev)}\n                      onPointerMove={onDragMove}\n                      onPointerUp={() => onDragEnd(ev)}\n                      onPointerCancel={() => onDragEnd(ev, true)}\n                      onLostPointerCapture={() => {\n                        if (dragRef.current) onDragEnd(ev, true);\n                      }}\n                      {...liftHandlers(ev.id, setHoveredId)}\n                      className={cn(\n                        \"absolute overflow-hidden rounded-md py-0.5 pl-2 pr-1 text-left text-[11px] leading-tight outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring\",\n                        lifted && \"shadow-md\",\n                        dragging ? \"z-30 cursor-grabbing\" : \"z-10\",\n                        onEventChange && !dragging && \"cursor-grab\",\n                        selectedId === ev.id && \"ring-1 ring-foreground/20\",\n                      )}\n                      style={{\n                        top: top + 1,\n                        height: h,\n                        left: `calc(${(b.col / b.cols) * 100}% + 2px)`,\n                        width: `calc(${100 / b.cols}% - 4px)`,\n                        background: tint(color, 22),\n                      }}\n                    >\n                      <span\n                        aria-hidden\n                        className=\"absolute inset-y-1 left-0.5 w-0.5 rounded-full\"\n                        style={{ background: color }}\n                      />\n                      <span\n                        className={cn(\n                          \"block font-medium\",\n                          compact ? \"truncate\" : \"line-clamp-2\",\n                        )}\n                      >\n                        {ev.title}\n                        {compact && (\n                          <span className=\"ml-1 font-normal text-muted-foreground\">\n                            {fmt.time.format(ev.start)}\n                          </span>\n                        )}\n                      </span>\n                      {!compact && (\n                        <span className=\"block truncate text-[10px] text-muted-foreground tabular-nums\">\n                          {timeLabel(ev)}\n                        </span>\n                      )}\n                    </motion.button>\n                  );\n                })}\n\n                {i === nowCol && (\n                  <div\n                    aria-hidden\n                    className=\"pointer-events-none absolute inset-x-0 z-20 flex items-center\"\n                    style={{ top: (nowMin / 60) * HOUR }}\n                  >\n                    <span className=\"-ml-1 size-2 shrink-0 -translate-y-1/2 rounded-full bg-destructive\" />\n                    <span className=\"h-0.5 flex-1 -translate-y-1/2 bg-destructive\" />\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\n// ── \"+N more\" day list ─────────────────────────────────────────────────────\n\nfunction DayPopover({\n  day,\n  style,\n  events,\n  title,\n  reduce,\n  timeLabel,\n  eventLabel,\n  onClose,\n  onEventClick,\n}: {\n  day: Date;\n  style: CSSProperties;\n  events: CalendarEvent[];\n  title: string;\n  reduce: boolean;\n  timeLabel: (e: CalendarEvent) => string;\n  eventLabel: (e: CalendarEvent) => string;\n  onClose: (restoreFocus: boolean) => void;\n  onEventClick: (e: CalendarEvent) => void;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    ref.current?.querySelector<HTMLButtonElement>(\"button\")?.focus();\n    const onDown = (e: PointerEvent) => {\n      const t = e.target as HTMLElement;\n      if (ref.current?.contains(t) || t.closest?.(\"[data-more-trigger]\")) return;\n      onClose(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [onClose]);\n\n  return (\n    <motion.div\n      ref={ref}\n      role=\"dialog\"\n      aria-label={title}\n      data-day={dayKey(day)}\n      initial={{ opacity: 0, scale: reduce ? 1 : 0.94 }}\n      animate={{ opacity: 1, scale: 1, transition: POPOVER_ENTER }}\n      exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: POPOVER_EXIT }}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") {\n          e.stopPropagation();\n          onClose(true);\n        }\n      }}\n      className=\"absolute z-40 m-1 flex max-h-[70%] flex-col overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg\"\n      style={style}\n    >\n      <p className=\"px-1.5 pb-1.5 pt-0.5 text-xs font-semibold\">{title}</p>\n      <ul className=\"flex min-h-0 flex-col gap-0.5 overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\">\n        {events.map((ev) => {\n          const color = eventColor(ev.color);\n          return (\n            <li key={ev.id}>\n              <button\n                type=\"button\"\n                aria-label={eventLabel(ev)}\n                onClick={() => onEventClick(ev)}\n                className=\"flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs outline-none transition-colors hover:bg-muted focus-visible:bg-muted\"\n              >\n                <span\n                  aria-hidden\n                  className=\"size-2 shrink-0 rounded-full\"\n                  style={{ background: color }}\n                />\n                <span className=\"min-w-0 flex-1 truncate font-medium\">\n                  {ev.title}\n                </span>\n                <span className=\"shrink-0 text-[10px] text-muted-foreground tabular-nums\">\n                  {ev.allDay ? \"All day\" : timeLabel(ev)}\n                </span>\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n    </motion.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"}]}