{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "week-grid",
  "type": "registry:ui",
  "title": "Week Grid",
  "description": "Time grid where overlaps sit side by side and you drag to create, move or resize.",
  "categories": [
    "scheduling"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/week-grid.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight, Clock, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type EventTone = \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\";\n\nexport type CalendarEvent = {\n  id: string;\n  title: string;\n  start: Date;\n  /** Exclusive. */\n  end: Date;\n  allDay?: boolean;\n  location?: string;\n  description?: string;\n  /** Meaning only: a deadline, an outage, a launch. Leave neutral for ordinary meetings. */\n  tone?: EventTone;\n  status?: \"confirmed\" | \"tentative\" | \"canceled\";\n};\n\n/* ------------------------------------------------------------------ */\n/* Date helpers                                                        */\n/* ------------------------------------------------------------------ */\n\nconst DAY_MIN = 1440;\nconst startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\nconst addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nconst dayNum = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate();\nconst cmpDay = (a: Date, b: Date) => dayNum(a) - dayNum(b);\nconst sameDay = (a: Date | null | undefined, b: Date | null | undefined) => !!a && !!b && dayNum(a) === dayNum(b);\nconst diffDays = (a: Date, b: Date) =>\n  Math.round((Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) - Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / 864e5);\nconst minutesOf = (d: Date) => d.getHours() * 60 + d.getMinutes();\n/** A wall-clock time on a day. Minutes past 1440 roll into the next day. */\nconst at = (day: Date, minutes: number) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, minutes);\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\nconst isAllDay = (e: CalendarEvent) => !!e.allDay || e.end.getTime() - e.start.getTime() >= 864e5;\nconst lastDay = (e: CalendarEvent) => {\n  const last = startOfDay(new Date(Math.max(e.end.getTime() - 1, e.start.getTime())));\n  return cmpDay(last, e.start) < 0 ? startOfDay(e.start) : last;\n};\n\nconst SUNDAY_REGIONS = new Set(\"AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW\".split(\" \"));\nconst SATURDAY_REGIONS = new Set(\"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY\".split(\" \"));\nfunction weekStartFor(locale: string): number {\n  try {\n    const l = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };\n    const info = l.getWeekInfo?.() ?? l.weekInfo;\n    if (info) return info.firstDay % 7;\n    const region = l.maximize().region ?? \"\";\n    return SUNDAY_REGIONS.has(region) ? 0 : SATURDAY_REGIONS.has(region) ? 6 : 1;\n  } catch {\n    return 0;\n  }\n}\n\n/* The clock and the viewer's language only exist in the browser: null / en-US */\n/* on the server, settled on hydration. The clock ticks on the minute.          */\n\nfunction subscribeMinute(notify: () => void) {\n  let timer = 0;\n  const arm = () => {\n    timer = window.setTimeout(() => (notify(), arm()), 60000 - (Date.now() % 60000) + 20);\n  };\n  arm();\n  const onVisible = () => document.visibilityState === \"visible\" && notify();\n  document.addEventListener(\"visibilitychange\", onVisible);\n  return () => {\n    window.clearTimeout(timer);\n    document.removeEventListener(\"visibilitychange\", onVisible);\n  };\n}\n/** The current minute, re-rendering once a minute. Null during the server render. */\nexport function useMinute(override?: Date) {\n  const m = useSyncExternalStore(subscribeMinute, () => Math.floor(Date.now() / 60000), () => null);\n  return useMemo(() => override ?? (m === null ? null : new Date(m * 60000)), [m, override]);\n}\nfunction subscribeLanguage(notify: () => void) {\n  window.addEventListener(\"languagechange\", notify);\n  return () => window.removeEventListener(\"languagechange\", notify);\n}\nconst useLocale = (locale?: string) => {\n  const browser = useSyncExternalStore(subscribeLanguage, () => navigator.language, () => \"en-US\");\n  return locale ?? browser;\n};\n\n/* ------------------------------------------------------------------ */\n/* Layout                                                              */\n/* ------------------------------------------------------------------ */\n\nexport type DaySegment = {\n  event: CalendarEvent;\n  key: string;\n  /** Minutes from the start of the day. */\n  start: number;\n  end: number;\n  /** Column inside its overlap group, how many columns the group has, and how many it may widen across. */\n  col: number;\n  cols: number;\n  span: number;\n  continuesBefore: boolean;\n  continuesAfter: boolean;\n};\n\n/**\n * Timed events for one day, laid side by side where they overlap. Each group of\n * transitively overlapping events shares a column count; an event widens into\n * neighboring columns that stay free for its whole length.\n */\nexport function layoutDay(day: Date, events: CalendarEvent[], minVisual = 20): DaySegment[] {\n  const d0 = day.getTime();\n  const d1 = addDays(day, 1).getTime();\n  const segs: (DaySegment & { vEnd: number })[] = [];\n  for (const ev of events) {\n    if (isAllDay(ev)) continue;\n    const s = ev.start.getTime();\n    const e = Math.max(ev.end.getTime(), s + 60000);\n    if (e <= d0 || s >= d1) continue;\n    const start = s < d0 ? 0 : minutesOf(ev.start);\n    const end = e >= d1 ? DAY_MIN : Math.max(start + 1, minutesOf(new Date(e)));\n    segs.push({ event: ev, key: s < d0 ? `${ev.id}@${dayKey(day)}` : ev.id, start, end, vEnd: Math.max(end, start + minVisual), col: 0, cols: 1, span: 1, continuesBefore: s < d0, continuesAfter: e > d1 });\n  }\n  segs.sort((a, b) => a.start - b.start || b.end - a.end);\n  let group: typeof segs = [];\n  let colEnds: number[] = [];\n  let groupEnd = -1;\n  const flush = () => {\n    const n = colEnds.length;\n    for (const s of group) {\n      s.cols = n;\n      let span = 1;\n      for (let c = s.col + 1; c < n; c++) {\n        if (group.some((o) => o.col === c && o.start < s.vEnd && o.vEnd > s.start)) break;\n        span++;\n      }\n      s.span = span;\n    }\n    group = [];\n    colEnds = [];\n  };\n  for (const s of segs) {\n    if (s.start >= groupEnd) {\n      flush();\n      groupEnd = -1;\n    }\n    let c = colEnds.findIndex((end) => end <= s.start);\n    if (c === -1) {\n      c = colEnds.length;\n      colEnds.push(s.vEnd);\n    } else colEnds[c] = s.vEnd;\n    s.col = c;\n    group.push(s);\n    groupEnd = Math.max(groupEnd, s.vEnd);\n  }\n  flush();\n  return segs;\n}\n\ntype AllDaySeg = { event: CalendarEvent; from: number; to: number; lane: number; continuesBefore: boolean; continuesAfter: boolean };\nfunction layoutAllDay(first: Date, count: number, events: CalendarEvent[]) {\n  const last = addDays(first, count - 1);\n  const segs: AllDaySeg[] = [];\n  for (const ev of events) {\n    if (!isAllDay(ev)) continue;\n    const a = startOfDay(ev.start);\n    const b = lastDay(ev);\n    if (cmpDay(b, first) < 0 || cmpDay(a, last) > 0) continue;\n    segs.push({ event: ev, from: Math.max(0, diffDays(first, a)), to: Math.min(count - 1, diffDays(first, b)), lane: 0, continuesBefore: cmpDay(a, first) < 0, continuesAfter: cmpDay(b, last) > 0 });\n  }\n  segs.sort((x, y) => x.from - y.from || y.to - y.from - (x.to - x.from) || x.event.start.getTime() - y.event.start.getTime());\n  const rows: boolean[][] = [];\n  for (const s of segs) {\n    for (let lane = 0; ; lane++) {\n      const row = (rows[lane] ??= Array(count).fill(false));\n      let free = true;\n      for (let c = s.from; c <= s.to && free; c++) free = !row[c];\n      if (!free) continue;\n      for (let c = s.from; c <= s.to; c++) row[c] = true;\n      s.lane = lane;\n      break;\n    }\n  }\n  const perDay = Array(count).fill(0);\n  for (const s of segs) for (let c = s.from; c <= s.to; c++) perDay[c]++;\n  return { segs, lanes: rows.length, perDay };\n}\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport type WeekGridProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  events?: CalendarEvent[];\n  defaultEvents?: CalendarEvent[];\n  onEventsChange?: (events: CalendarEvent[]) => void;\n  /** Called with the new event once its title is confirmed. */\n  onEventCreate?: (event: CalendarEvent) => void;\n  /** Called after an event is moved or resized, by pointer or keyboard. */\n  onEventChange?: (event: CalendarEvent, previous: CalendarEvent) => void;\n  onEventClick?: (event: CalendarEvent) => void;\n  /** Any date in the range to show (controlled). */\n  date?: Date;\n  defaultDate?: Date;\n  onDateChange?: (date: Date) => void;\n  /** Days side by side, 1–7. Defaults to a full week, or three days when narrower than 560px. */\n  days?: number;\n  locale?: string;\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** Pixels per hour. */\n  hourHeight?: number;\n  /** Minutes every drag and nudge snaps to. */\n  snapMinutes?: number;\n  /** Length of an event made with a single click, in minutes. */\n  defaultDuration?: number;\n  /** Hour to scroll to on mount. Defaults to an hour before now, or 8 AM. */\n  scrollToHour?: number;\n  loading?: boolean;\n  /** Open events, but no creating, moving or resizing. */\n  readOnly?: boolean;\n  /** Pin the clock for tests and screenshots. */\n  now?: Date;\n  labels?: { today?: string; previous?: string; next?: string; allDay?: string; newEvent?: string; create?: string; titlePlaceholder?: string };\n};\n\ntype Slide = { dir: number; instant: boolean; reduce: boolean };\nconst colsVariants = {\n  enter: (c: Slide) => (c.instant ? { opacity: 1, x: 0 } : { opacity: 0, x: c.reduce ? 0 : c.dir * 24 }),\n  center: (c: Slide) => ({ opacity: 1, x: 0, transition: { duration: c.instant ? 0 : c.reduce ? 0.15 : 0.28, ease: ease.out } }),\n  exit: (c: Slide) =>\n    c.instant ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, x: c.reduce ? 0 : c.dir * -24, transition: { duration: c.reduce ? 0.1 : 0.18, ease: ease.out } },\n};\nconst titleVariants = {\n  enter: (c: Slide) => (c.instant ? { opacity: 1, y: 0 } : { opacity: 0, y: c.reduce ? 0 : c.dir * 8, filter: c.reduce ? \"blur(0px)\" : \"blur(2px)\" }),\n  center: (c: Slide) => ({ opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: c.instant ? 0 : 0.24, ease: ease.out } }),\n  exit: (c: Slide) =>\n    c.instant ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: c.reduce ? 0 : c.dir * -8, filter: c.reduce ? \"blur(0px)\" : \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } },\n};\n\nconst toneEdge: Record<EventTone, string> = { neutral: \"before:bg-fg-3\", info: \"before:bg-info\", success: \"before:bg-success\", warning: \"before:bg-warning\", danger: \"before:bg-danger\" };\nconst toneFill: Record<EventTone, string> = { neutral: \"bg-[color-mix(in_oklab,var(--fg)_9%,var(--raised))]\", info: \"bg-[color-mix(in_oklab,var(--info)_16%,var(--raised))]\", success: \"bg-[color-mix(in_oklab,var(--success)_16%,var(--raised))]\", warning: \"bg-[color-mix(in_oklab,var(--warning)_16%,var(--raised))]\", danger: \"bg-[color-mix(in_oklab,var(--danger)_16%,var(--raised))]\" };\n\ntype Draft = { day: number; start: number; end: number; title: string; phase: \"drag\" | \"name\" };\ntype Drag = { id: string; mode: \"move\" | \"resize\"; day: number; start: number; end: number };\ntype Gesture = {\n  kind: \"create\" | \"move\" | \"resize\";\n  pointerId: number;\n  pointerType: string;\n  x: number;\n  y: number;\n  active: boolean;\n  timer: number;\n  day: number;\n  anchor: number;\n  event?: CalendarEvent;\n  /** Minutes between the pointer and the event's start when it was picked up. */\n  grab?: number;\n  duration?: number;\n  lastX: number;\n  lastY: number;\n};\n\nconst GUTTER = 52;\n\nexport function WeekGrid({\n  events: eventsProp,\n  defaultEvents = [],\n  onEventsChange,\n  onEventCreate,\n  onEventChange,\n  onEventClick,\n  date: dateProp,\n  defaultDate,\n  onDateChange,\n  days: daysProp,\n  locale: localeProp,\n  weekStartsOn,\n  hourHeight = 48,\n  snapMinutes = 15,\n  defaultDuration = 30,\n  scrollToHour,\n  loading = false,\n  readOnly = false,\n  now: nowProp,\n  labels,\n  className,\n  ref,\n  ...rest\n}: WeekGridProps) {\n  const L = { today: \"Today\", previous: \"Previous\", next: \"Next\", allDay: \"All day\", newEvent: \"New event\", create: \"Create event\", titlePlaceholder: \"Add title\", ...labels };\n  const reduce = !!useReducedMotion();\n  const locale = useLocale(localeProp);\n  const now = useMinute(nowProp);\n  const today = useMemo(() => (now ? startOfDay(now) : null), [now]);\n  const ws = weekStartsOn ?? weekStartFor(locale);\n  const uid = useId();\n  const pxPerMin = hourHeight / 60;\n  const snap = Math.max(1, snapMinutes);\n\n  const [events, setEvents] = useControllableState({ value: eventsProp, defaultValue: defaultEvents, onChange: onEventsChange });\n  const [anchorState, setAnchor] = useControllableState<Date | null>({ value: dateProp, defaultValue: defaultDate ?? null, onChange: (d) => d && onDateChange?.(d) });\n  const anchor = anchorState ? startOfDay(anchorState) : today;\n\n  // Width decides the day count when none is given.\n  const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!rootEl) return;\n    const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));\n    ro.observe(rootEl);\n    return () => ro.disconnect();\n  }, [rootEl]);\n  const setRoot = useCallback(\n    (el: HTMLDivElement | null) => {\n      setRootEl(el);\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) ref.current = el;\n    },\n    [ref],\n  );\n  const count = Math.min(7, Math.max(1, daysProp ?? (width > 0 && width < 560 ? 3 : 7)));\n  const first = useMemo(() => (anchor ? (count === 7 ? addDays(anchor, -((anchor.getDay() - ws + 7) % 7)) : anchor) : null), [anchor, count, ws]);\n  const dayList = useMemo(() => (first ? Array.from({ length: count }, (_, i) => addDays(first, i)) : null), [first, count]);\n  const rangeKey = first ? dayKey(first) : \"none\";\n  const [slide, setSlide] = useState<Slide>({ dir: 1, instant: true, reduce });\n\n  const fmt = useMemo(() => {\n    const make = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, o);\n    return {\n      range: make({ month: \"short\", day: \"numeric\", year: \"numeric\" }),\n      monthYear: make({ month: \"long\", year: \"numeric\" }),\n      weekday: make({ weekday: \"short\" }),\n      dayNum: make({ day: \"numeric\" }),\n      long: make({ weekday: \"long\", day: \"numeric\", month: \"long\" }),\n      short: make({ weekday: \"short\", day: \"numeric\", month: \"short\" }),\n      time: make({ hour: \"numeric\", minute: \"2-digit\" }),\n      hour: make({ hour: \"numeric\" }),\n      clock: make({ hour: \"numeric\", minute: \"2-digit\", hour12: false }),\n    };\n  }, [locale]);\n  const timeRange = useCallback((s: Date, e: Date) => fmt.time.formatRange(s, e), [fmt]);\n  // Blocks are narrow: \"4 – 5:45 PM\", not \"4:00 PM – 5:45 PM\".\n  const compactRange = useCallback(\n    (s: Date, e: Date) => {\n      const parts = (d: Date) => (d.getMinutes() ? fmt.time : fmt.hour).formatToParts(d);\n      const period = (p: Intl.DateTimeFormatPart[]) => p.find((x) => x.type === \"dayPeriod\")?.value;\n      const text = (p: Intl.DateTimeFormatPart[], drop: boolean) => p.filter((x) => !(drop && x.type === \"dayPeriod\")).map((x) => x.value).join(\"\").trim();\n      const ps = parts(s);\n      const pe = parts(e);\n      return `${text(ps, period(ps) === period(pe))} – ${text(pe, false)}`;\n    },\n    [fmt],\n  );\n  const duration = (mins: number) => (mins < 60 ? `${mins}m` : mins % 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins / 60}h`);\n\n  const segsByDay = useMemo(() => (dayList && !loading ? dayList.map((d) => layoutDay(d, events)) : null), [dayList, events, loading]);\n  const allDay = useMemo(() => (first && !loading ? layoutAllDay(first, count, events) : { segs: [], lanes: 0, perDay: [] as number[] }), [first, count, events, loading]);\n  const [allDayOpen, setAllDayOpen] = useState(false);\n  const allDayLanes = allDayOpen ? allDay.lanes : Math.min(allDay.lanes, 2);\n  const allDayHidden = useMemo(() => {\n    if (allDayOpen || allDay.lanes <= 2) return null;\n    const hidden = Array(count).fill(0);\n    for (const s of allDay.segs) if (s.lane >= 2) for (let c = s.from; c <= s.to; c++) hidden[c]++;\n    return hidden;\n  }, [allDay, allDayOpen, count]);\n\n  /* ---------- scroll ---------- */\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const scrolled = useRef(false);\n  useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el || scrolled.current || !now) return;\n    scrolled.current = true;\n    const hour = scrollToHour ?? Math.max(0, Math.min(minutesOf(now) / 60 - 1.5, 24));\n    el.scrollTop = Math.max(0, hour * hourHeight - (scrollToHour === undefined ? 0 : 8));\n  }, [now, scrollToHour, hourHeight]);\n\n  /* ---------- popovers ---------- */\n  const [panel, setPanel] = useState<{ open: boolean; event: CalendarEvent | null; anchor: Element | null }>({ open: false, event: null, anchor: null });\n  const [draft, setDraft] = useState<Draft | null>(null);\n  const [draftEl, setDraftEl] = useState<HTMLElement | null>(null);\n  const [drag, setDrag] = useState<Drag | null>(null);\n  const [landed, setLanded] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const returnFocus = useRef<HTMLElement | null>(null);\n\n  /* ---------- navigation ---------- */\n  const go = (dir: number) => {\n    if (!anchor) return;\n    setSlide({ dir, instant: false, reduce });\n    setDraft(null);\n    setPanel((p) => ({ ...p, open: false }));\n    setAnchor(addDays(anchor, dir * count));\n  };\n  const goToday = () => {\n    if (!today || !anchor || !first) return;\n    const inRange = cmpDay(today, first) >= 0 && diffDays(first, today) < count;\n    if (!inRange) {\n      setSlide({ dir: cmpDay(today, first) > 0 ? 1 : -1, instant: false, reduce });\n      setDraft(null);\n      setAnchor(today);\n    }\n    const el = scrollerRef.current;\n    if (el && now) el.scrollTo({ top: Math.max(0, (minutesOf(now) / 60 - 1.5) * hourHeight), behavior: reduce ? \"auto\" : \"smooth\" });\n  };\n\n  /* ---------- pointer gestures ---------- */\n  const colsRef = useRef<HTMLDivElement | null>(null);\n  const hoverRef = useRef<HTMLDivElement | null>(null);\n  const gesture = useRef<Gesture | null>(null);\n  const justDragged = useRef(false);\n  const suppress = useRef(false);\n  const autoScroll = useRef(0);\n\n  const locate = (x: number, y: number) => {\n    const el = colsRef.current;\n    if (!el) return { day: 0, min: 0 };\n    const r = el.getBoundingClientRect();\n    const day = Math.max(0, Math.min(count - 1, Math.floor(((x - r.left) / r.width) * count)));\n    const min = Math.max(0, Math.min(DAY_MIN, (y - r.top) / pxPerMin));\n    return { day, min };\n  };\n  const floorSnap = (m: number) => Math.floor(m / snap) * snap;\n  const roundSnap = (m: number) => Math.round(m / snap) * snap;\n\n  const update = (g: Gesture) => {\n    const p = locate(g.lastX, g.lastY);\n    if (g.kind === \"create\") {\n      const m = p.min;\n      const start = m >= g.anchor ? g.anchor : floorSnap(m);\n      const end = m >= g.anchor ? Math.min(DAY_MIN, Math.max(g.anchor + snap, Math.ceil(m / snap) * snap)) : g.anchor + snap;\n      setDraft((d) => (d && d.start === start && d.end === end ? d : { day: g.day, start, end, title: \"\", phase: \"drag\" }));\n    } else if (g.kind === \"move\") {\n      const dur = g.duration!;\n      const start = Math.max(0, Math.min(DAY_MIN - Math.min(dur, DAY_MIN), roundSnap(p.min - g.grab!)));\n      const next = { id: g.event!.id, mode: \"move\" as const, day: p.day, start, end: start + dur };\n      setDrag((d) => (d && d.day === next.day && d.start === next.start ? d : next));\n    } else {\n      setDrag((d) => {\n        if (!d) return d;\n        const end = Math.max(d.start + snap, Math.min(DAY_MIN, roundSnap(p.min)));\n        return end === d.end ? d : { ...d, end };\n      });\n    }\n  };\n\n  const stopAutoScroll = () => {\n    cancelAnimationFrame(autoScroll.current);\n    autoScroll.current = 0;\n  };\n  // While dragging near the top or bottom edge, the grid scrolls under the pointer.\n  const runAutoScroll = () => {\n    const tick = () => {\n      const g = gesture.current;\n      const el = scrollerRef.current;\n      if (!g?.active || !el) return stopAutoScroll();\n      const r = el.getBoundingClientRect();\n      const topEdge = r.top + 76;\n      const speed = g.lastY < topEdge + 32 ? -Math.min(14, (topEdge + 32 - g.lastY) / 3) : g.lastY > r.bottom - 32 ? Math.min(14, (g.lastY - (r.bottom - 32)) / 3) : 0;\n      if (speed) {\n        el.scrollTop += speed;\n        update(g);\n      }\n      autoScroll.current = requestAnimationFrame(tick);\n    };\n    stopAutoScroll();\n    autoScroll.current = requestAnimationFrame(tick);\n  };\n\n  const activate = (g: Gesture) => {\n    g.active = true;\n    try {\n      colsRef.current?.setPointerCapture(g.pointerId);\n    } catch {}\n    if (hoverRef.current) hoverRef.current.dataset.show = \"false\";\n    setPanel((p) => ({ ...p, open: false }));\n    if (g.kind === \"create\") setDraft({ day: g.day, start: g.anchor, end: g.anchor + snap, title: \"\", phase: \"drag\" });\n    else {\n      const seg = segsByDay?.[g.day]?.find((s) => s.event.id === g.event!.id);\n      if (seg) setDrag({ id: g.event!.id, mode: g.kind, day: g.day, start: seg.start, end: seg.end });\n    }\n    runAutoScroll();\n  };\n\n  const cancelGesture = useCallback(() => {\n    const g = gesture.current;\n    if (!g) return;\n    window.clearTimeout(g.timer);\n    try {\n      colsRef.current?.releasePointerCapture(g.pointerId);\n    } catch {}\n    gesture.current = null;\n    stopAutoScroll();\n    if (g.active) {\n      setDrag(null);\n      if (g.kind === \"create\") setDraft(null);\n    }\n  }, []);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && gesture.current?.active && (e.preventDefault(), cancelGesture());\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [cancelGesture]);\n  useEffect(() => () => stopAutoScroll(), []);\n\n  // A touch drag only begins after a long press; from then on the page mustn't scroll.\n  useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const onTouchMove = (e: TouchEvent) => gesture.current?.active && e.cancelable && e.preventDefault();\n    el.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    return () => el.removeEventListener(\"touchmove\", onTouchMove);\n  }, []);\n\n  const commitMove = (id: string, day: number, start: number, end: number, how: \"move\" | \"resize\") => {\n    const prev = events.find((e) => e.id === id);\n    if (!prev || !dayList) return;\n    let next: CalendarEvent;\n    if (how === \"move\") {\n      const s = at(dayList[day], start);\n      next = { ...prev, start: s, end: new Date(s.getTime() + (prev.end.getTime() - prev.start.getTime())) };\n    } else {\n      next = { ...prev, end: at(dayList[day], end) };\n    }\n    if (next.start.getTime() === prev.start.getTime() && next.end.getTime() === prev.end.getTime()) return;\n    setEvents(events.map((e) => (e.id === id ? next : e)));\n    onEventChange?.(next, prev);\n    setAnnouncement(`${next.title}, ${fmt.short.format(next.start)}, ${timeRange(next.start, next.end)}`);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    const busy = suppress.current;\n    suppress.current = false;\n    if (e.button !== 0 || gesture.current || !dayList) return;\n    const target = e.target as HTMLElement;\n    const evEl = target.closest<HTMLElement>(\"[data-event-id]\");\n    const p = locate(e.clientX, e.clientY);\n    const base = { pointerId: e.pointerId, pointerType: e.pointerType, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, active: false, timer: 0 };\n    if (evEl) {\n      if (readOnly || evEl.dataset.cont) return;\n      const ev = events.find((x) => x.id === evEl.dataset.eventId);\n      if (!ev) return;\n      const day = Math.max(0, diffDays(dayList[0], ev.start));\n      const resize = !!target.closest(\"[data-resize]\");\n      gesture.current = { ...base, kind: resize ? \"resize\" : \"move\", day, anchor: 0, event: ev, grab: p.min - minutesOf(ev.start), duration: Math.round((ev.end.getTime() - ev.start.getTime()) / 60000) };\n    } else {\n      if (readOnly || busy) return;\n      gesture.current = { ...base, kind: \"create\", day: p.day, anchor: floorSnap(p.min) };\n    }\n    const g = gesture.current;\n    if (e.pointerType === \"touch\") g.timer = window.setTimeout(() => gesture.current === g && activate(g), 380);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const g = gesture.current;\n    if (!g) {\n      // Hover: a faint slot follows the pointer, showing where a click would land.\n      const h = hoverRef.current;\n      if (!h || readOnly || e.pointerType !== \"mouse\" || (e.target as HTMLElement).closest(\"[data-event-id]\") || draft) {\n        if (h) h.dataset.show = \"false\";\n        return;\n      }\n      const p = locate(e.clientX, e.clientY);\n      const start = floorSnap(Math.min(p.min, DAY_MIN - defaultDuration));\n      h.style.transform = `translateY(${start * pxPerMin}px)`;\n      h.style.left = `${(p.day / count) * 100}%`;\n      h.dataset.show = \"true\";\n      h.firstElementChild!.textContent = fmt.time.format(at(dayList?.[p.day] ?? new Date(0), start));\n      return;\n    }\n    if (e.pointerId !== g.pointerId) return;\n    g.lastX = e.clientX;\n    g.lastY = e.clientY;\n    if (!g.active) {\n      const moved = Math.hypot(e.clientX - g.x, e.clientY - g.y);\n      if (g.pointerType === \"touch\") {\n        if (moved > 8) cancelGesture();\n        return;\n      }\n      if (moved < 4) return;\n      activate(g);\n    }\n    update(g);\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    const g = gesture.current;\n    if (!g || e.pointerId !== g.pointerId) return;\n    window.clearTimeout(g.timer);\n    gesture.current = null;\n    stopAutoScroll();\n    try {\n      colsRef.current?.releasePointerCapture(g.pointerId);\n    } catch {}\n    if (!g.active) {\n      if (g.kind === \"create\") {\n        const start = Math.min(g.anchor, DAY_MIN - defaultDuration);\n        setPanel((p) => ({ ...p, open: false }));\n        setDraft({ day: g.day, start, end: start + defaultDuration, title: \"\", phase: \"name\" });\n      }\n      return;\n    }\n    justDragged.current = true;\n    window.setTimeout(() => (justDragged.current = false), 0);\n    if (g.kind === \"create\") setDraft((d) => (d ? { ...d, phase: \"name\" } : d));\n    else if (drag) {\n      commitMove(drag.id, drag.day, drag.start, drag.end, drag.mode);\n      setDrag(null);\n    } else setDrag(null);\n  };\n\n  /* ---------- keyboard ---------- */\n  // Moving an event to another day remounts it in that column; put focus back on it.\n  const refocus = useRef<string | null>(null);\n  useEffect(() => {\n    const id = refocus.current;\n    if (!id) return;\n    refocus.current = null;\n    colsRef.current?.querySelector<HTMLElement>(`[data-event-id=\"${CSS.escape(id)}\"]:not([data-cont])`)?.focus();\n  }, [events]);\n\n  const onEventKeyDown = (e: React.KeyboardEvent<HTMLElement>, ev: CalendarEvent) => {\n    if (readOnly || !e.altKey || !dayList) return;\n    const dm = { ArrowUp: -snap, ArrowDown: snap }[e.key as \"ArrowUp\" | \"ArrowDown\"];\n    const dd = { ArrowLeft: -1, ArrowRight: 1 }[e.key as \"ArrowLeft\" | \"ArrowRight\"];\n    if (!dm && !dd) return;\n    e.preventDefault();\n    refocus.current = ev.id;\n    const day = Math.max(0, diffDays(dayList[0], ev.start));\n    const start = minutesOf(ev.start);\n    const dur = Math.round((ev.end.getTime() - ev.start.getTime()) / 60000);\n    if (e.shiftKey && dm) {\n      const end = Math.max(start + snap, Math.min(DAY_MIN, start + dur + dm));\n      commitMove(ev.id, day, start, end, \"resize\");\n    } else if (dm) {\n      commitMove(ev.id, day, Math.max(0, Math.min(DAY_MIN - dur, start + dm)), 0, \"move\");\n    } else if (dd) {\n      const nd = day + dd;\n      if (nd < 0 || nd >= count) return;\n      commitMove(ev.id, nd, start, 0, \"move\");\n    }\n  };\n\n  // N starts an event at the next free-looking slot: after now if today is visible, otherwise 9 AM on the first day.\n  const onGridKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (readOnly || e.key.toLowerCase() !== \"n\" || e.metaKey || e.ctrlKey || e.altKey || !dayList) return;\n    if ((e.target as HTMLElement).closest(\"input, textarea, [contenteditable]\")) return;\n    e.preventDefault();\n    const todayIdx = today ? dayList.findIndex((d) => sameDay(d, today)) : -1;\n    const start = todayIdx >= 0 && now ? Math.min(DAY_MIN - defaultDuration, Math.ceil(minutesOf(now) / snap) * snap) : 9 * 60;\n    const day = todayIdx >= 0 ? todayIdx : 0;\n    returnFocus.current = document.activeElement as HTMLElement | null;\n    setDraft({ day, start, end: start + defaultDuration, title: \"\", phase: \"name\" });\n    scrollerRef.current?.scrollTo({ top: Math.max(0, start * pxPerMin - 80) });\n  };\n\n  const commitDraft = () => {\n    if (!draft || !draft.title.trim() || !dayList) return;\n    const id = typeof crypto !== \"undefined\" && crypto.randomUUID ? crypto.randomUUID() : `evt-${Date.now()}`;\n    const created: CalendarEvent = { id, title: draft.title.trim(), start: at(dayList[draft.day], draft.start), end: at(dayList[draft.day], draft.end) };\n    setLanded(id);\n    setEvents([...events, created]);\n    onEventCreate?.(created);\n    setAnnouncement(`Created ${created.title}, ${fmt.short.format(created.start)}, ${timeRange(created.start, created.end)}`);\n    setDraft(null);\n  };\n\n  /* ---------- render ---------- */\n  const nowMin = now ? minutesOf(now) : null;\n  const todayIdx = dayList && today ? dayList.findIndex((d) => sameDay(d, today)) : -1;\n  const title = first && dayList ? (count === 1 ? fmt.long.format(first) : fmt.range.formatRange(first, dayList[count - 1])) : \"\";\n  const layoutT = reduce ? { duration: 0 } : spring.snappy;\n  const cols = `repeat(${count}, minmax(0, 1fr))`;\n  const rangeEmpty = !loading && !!segsByDay && segsByDay.every((d) => d.length === 0) && allDay.segs.length === 0;\n\n  return (\n    <div\n      data-slot=\"week-grid\"\n      ref={setRoot}\n      className={cn(\"flex min-h-[420px] min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n      onPointerDownCapture={() => {\n        // A press that only dismisses an open popover shouldn't also start a new event.\n        suppress.current = panel.open || draft?.phase === \"name\";\n      }}\n      onKeyDown={onGridKeyDown}\n      {...rest}\n    >\n      {/* Header */}\n      <div className=\"flex h-12 shrink-0 items-center gap-2 border-b border-line pl-4 pr-2\">\n        <h2 className=\"relative min-w-0 flex-1 overflow-hidden text-[15px] font-medium tracking-[-0.015em]\" aria-live=\"polite\">\n          <span className=\"invisible\">Sep 00 – 00, 0000</span>\n          <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n            <motion.span\n              key={rangeKey}\n              custom={slide}\n              variants={titleVariants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              className=\"absolute inset-y-0 left-0 flex items-center gap-2 whitespace-nowrap tabular\"\n            >\n              {title}\n              {rangeEmpty && <span className=\"hidden text-[12px] font-normal tracking-normal text-fg-4 sm:inline\">No events</span>}\n            </motion.span>\n          </AnimatePresence>\n        </h2>\n        <button type=\"button\" onClick={goToday} disabled={!today} className={cn(btnBase, \"border border-line-2 px-2.5 text-[12px] text-fg-2 hover:border-fg-4 hover:bg-hover hover:text-fg disabled:opacity-50\")}>\n          {L.today}\n        </button>\n        <div className=\"flex items-center\">\n          <NavButton label={count === 7 ? \"Previous week\" : `${L.previous} ${count} days`} onClick={() => go(-1)} disabled={!anchor}>\n            <ChevronLeft />\n          </NavButton>\n          <NavButton label={count === 7 ? \"Next week\" : `${L.next} ${count} days`} onClick={() => go(1)} disabled={!anchor}>\n            <ChevronRight />\n          </NavButton>\n        </div>\n      </div>\n\n      <motion.div ref={scrollerRef} layoutScroll className=\"relative min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]\" aria-busy={loading || !now || undefined}>\n        {/* Day headers and the all-day row stay pinned while the hours scroll. */}\n        <div className=\"sticky top-0 z-(--z-sticky) border-b border-line bg-raised\">\n          <div className=\"flex\">\n            <div style={{ width: GUTTER }} className=\"shrink-0\" />\n            <div className=\"relative min-w-0 flex-1 overflow-hidden\">\n              <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n                <motion.div key={rangeKey} custom={slide} variants={colsVariants} initial=\"enter\" animate=\"center\" exit=\"exit\" className=\"grid\" style={{ gridTemplateColumns: cols }}>\n                  {Array.from({ length: count }, (_, i) => {\n                    const d = dayList?.[i];\n                    const isToday = sameDay(d, today);\n                    return (\n                      <div key={i} className=\"flex h-11 min-w-0 items-center gap-1.5 border-l border-line px-2\" aria-current={isToday ? \"date\" : undefined}>\n                        {d && (\n                          <>\n                            <span className={cn(\"font-mono text-2xs uppercase tracking-[0.08em]\", isToday ? \"text-fg\" : \"text-fg-3\")}>{fmt.weekday.format(d)}</span>\n                            <span className={cn(\"grid h-6 min-w-6 place-items-center rounded-full px-1 text-[13px] tabular\", isToday ? \"bg-fg font-medium text-frame\" : \"text-fg-2\")}>{fmt.dayNum.format(d)}</span>\n                          </>\n                        )}\n                      </div>\n                    );\n                  })}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </div>\n          <div className=\"flex border-t border-line\">\n            <div style={{ width: GUTTER }} className=\"flex shrink-0 items-start justify-end pr-2 pt-[7px] text-2xs text-fg-4\">\n              {allDay.lanes > 2 ? (\n                <button\n                  type=\"button\"\n                  onClick={() => setAllDayOpen((o) => !o)}\n                  aria-expanded={allDayOpen}\n                  className=\"flex items-center gap-0.5 rounded text-fg-3 outline-none transition-colors hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n                >\n                  {L.allDay}\n                  <svg width=\"10\" height=\"10\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={cn(\"transition-transform duration-200 ease-out-expo\", allDayOpen && \"rotate-180\")}>\n                    <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n                  </svg>\n                </button>\n              ) : (\n                L.allDay\n              )}\n            </div>\n            <div className=\"relative min-w-0 flex-1 overflow-hidden\">\n              <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n                <motion.div\n                  key={rangeKey}\n                  custom={slide}\n                  variants={colsVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  className=\"relative grid py-1\"\n                  style={{ gridTemplateColumns: cols, gridTemplateRows: `repeat(${Math.max(1, allDayLanes + (allDayHidden ? 1 : 0))}, 20px)`, rowGap: 2, minHeight: 30 }}\n                >\n                  {Array.from({ length: count }, (_, i) => (\n                    <div key={i} className=\"border-l border-line\" style={{ gridColumn: i + 1, gridRow: \"1 / -1\", marginBlock: -4 }} />\n                  ))}\n                  {allDay.segs\n                    .filter((s) => s.lane < allDayLanes)\n                    .map((s) => (\n                      <button\n                        key={s.event.id}\n                        type=\"button\"\n                        data-event-chip\n                        aria-haspopup=\"dialog\"\n                        aria-label={`${s.event.title}, all day`}\n                        onClick={(e) => openEvent(s.event, e.currentTarget)}\n                        className={cn(\n                          \"relative flex min-w-0 items-center rounded-[5px] pl-2.5 pr-1.5 text-left text-[11.5px] font-medium leading-none text-fg select-none\",\n                          \"outline-none transition-[scale,box-shadow] duration-150 hover:shadow-[inset_0_0_0_1px_var(--line-2)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 active:scale-[0.98]\",\n                          \"before:absolute before:inset-y-[3px] before:left-[3px] before:w-[2px] before:rounded-full\",\n                          s.event.status === \"tentative\" ? \"border border-dashed border-fg-4 before:bg-fg-4\" : cn(toneFill[s.event.tone ?? \"neutral\"], toneEdge[s.event.tone ?? \"neutral\"]),\n                          s.continuesBefore ? \"ml-0 rounded-l-none before:hidden\" : \"ml-1\",\n                          s.continuesAfter ? \"mr-0 rounded-r-none\" : \"mr-1\",\n                        )}\n                        style={{ gridColumn: `${s.from + 1} / ${s.to + 2}`, gridRow: s.lane + 1 }}\n                      >\n                        <span className=\"truncate\">{s.event.title}</span>\n                      </button>\n                    ))}\n                  {allDayHidden?.map((n, c) =>\n                    n > 0 ? (\n                      <button\n                        key={`more-${c}`}\n                        type=\"button\"\n                        onClick={() => setAllDayOpen(true)}\n                        className=\"mx-1 flex items-center rounded-[5px] px-1.5 text-left text-[11px] font-medium text-fg-3 outline-none transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.06] hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 active:scale-[0.97]\"\n                        style={{ gridColumn: c + 1, gridRow: 3 }}\n                      >\n                        +{n} more\n                      </button>\n                    ) : null,\n                  )}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </div>\n        </div>\n\n        {/* Hours */}\n        <div className=\"relative flex\" style={{ height: 24 * hourHeight }}>\n          <div style={{ width: GUTTER }} className=\"relative shrink-0 select-none\" aria-hidden>\n            {Array.from({ length: 23 }, (_, i) => {\n              const h = i + 1;\n              const nearNow = todayIdx >= 0 && nowMin !== null && Math.abs(nowMin - h * 60) < 16;\n              return (\n                // Node and the browser can space \"1 PM\" differently (ICU versions), so the server text may differ by a character.\n                <span key={h} suppressHydrationWarning className={cn(\"absolute right-2 -translate-y-1/2 text-2xs text-fg-4 tabular transition-opacity duration-150\", nearNow && \"opacity-0\")} style={{ top: h * hourHeight }}>\n                  {fmt.hour.format(at(new Date(2026, 0, 5), h * 60))}\n                </span>\n              );\n            })}\n            {todayIdx >= 0 && nowMin !== null && (\n              <span className=\"absolute right-1.5 z-[3] -translate-y-1/2 rounded bg-fg px-1 py-px font-mono text-[10px] leading-[14px] text-frame tabular\" style={{ top: nowMin * pxPerMin }}>\n                {fmt.clock.format(now!)}\n              </span>\n            )}\n          </div>\n          <div className=\"relative min-w-0 flex-1 overflow-hidden\">\n            <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n              <motion.div\n                key={rangeKey}\n                // The outgoing range unmounts after the incoming one mounts; ignore its null so the live grid keeps the ref.\n                ref={(el: HTMLDivElement | null) => {\n                  if (el) colsRef.current = el;\n                }}\n                custom={slide}\n                variants={colsVariants}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                role=\"group\"\n                aria-label={title}\n                className={cn(\"absolute inset-0 grid touch-pan-y\", !readOnly && \"cursor-default\")}\n                style={{\n                  gridTemplateColumns: cols,\n                  backgroundImage: `linear-gradient(to bottom, var(--line) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--line) 50%, transparent) 1px, transparent 1px)`,\n                  backgroundSize: `100% ${hourHeight}px, 100% ${hourHeight / 2}px`,\n                  backgroundPosition: `0 0, 0 ${hourHeight / 2}px`,\n                }}\n                onPointerDown={onPointerDown}\n                onPointerMove={onPointerMove}\n                onPointerUp={onPointerUp}\n                onPointerCancel={cancelGesture}\n                onPointerLeave={() => hoverRef.current && (hoverRef.current.dataset.show = \"false\")}\n              >\n                {Array.from({ length: count }, (_, i) => {\n                  const d = dayList?.[i];\n                  const past = !!(d && today && cmpDay(d, today) < 0);\n                  const segs = segsByDay?.[i] ?? [];\n                  return (\n                    <div key={i} data-day={d ? dayKey(d) : undefined} className={cn(\"relative min-w-0 border-l border-line\", past && \"bg-page/30\")}>\n                      {(loading || !now) &&\n                        [0, 1].map((k) => {\n                          const start = 9 * 60 + ((i * 97 + k * 190) % 420);\n                          return (\n                            <span\n                              key={k}\n                              className=\"absolute inset-x-1 rounded-md bg-fg/[0.06] animate-pulse-soft motion-reduce:animate-none\"\n                              style={{ top: start * pxPerMin, height: (k ? 45 : 60) * pxPerMin }}\n                            />\n                          );\n                        })}\n                      {/* Leave a strip on the right so there's always somewhere to click to create. */}\n                      <div className=\"absolute inset-y-0 left-0.5 right-2.5\">\n                        {segs.map((s) => {\n                          const isDragged = drag?.id === s.event.id && !s.continuesBefore;\n                          if (isDragged) {\n                            return (\n                              <div\n                                key={`ph-${s.key}`}\n                                aria-hidden\n                                className=\"absolute rounded-md border border-dashed border-line-2\"\n                                style={{ top: s.start * pxPerMin, height: Math.max(18, (s.end - s.start) * pxPerMin) - 1, left: `${(s.col / s.cols) * 100}%`, width: `calc(${(s.span / s.cols) * 100}% - 2px)` }}\n                              />\n                            );\n                          }\n                          return (\n                            <EventBlock\n                              key={s.key}\n                              seg={s}\n                              layoutId={`${uid}-${landed === s.event.id ? \"draft\" : s.key}`}\n                              layoutT={layoutT}\n                              pxPerMin={pxPerMin}\n                              past={!!(now && s.event.end <= now)}\n                              readOnly={readOnly}\n                              label={compactRange(s.event.start, s.event.end)}\n                              longLabel={`${s.event.title}, ${fmt.short.format(s.event.start)}, ${timeRange(s.event.start, s.event.end)}${s.event.location ? `, ${s.event.location}` : \"\"}`}\n                              onOpen={(el) => {\n                                if (justDragged.current) return;\n                                openEvent(s.event, el);\n                              }}\n                              onKeyDown={(e) => onEventKeyDown(e, s.event)}\n                            />\n                          );\n                        })}\n                        {drag && drag.day === i && (\n                          <DragBlock\n                            key=\"drag\"\n                            event={events.find((e) => e.id === drag.id)!}\n                            layoutId={`${uid}-${drag.id}`}\n                            layoutT={layoutT}\n                            top={drag.start * pxPerMin}\n                            height={Math.max(18, (drag.end - drag.start) * pxPerMin) - 1}\n                            label={d ? compactRange(at(d, drag.start), at(d, drag.end)) : \"\"}\n                          />\n                        )}\n                        {draft && draft.day === i && d && (\n                          <motion.div\n                            ref={setDraftEl}\n                            layoutId={`${uid}-draft`}\n                            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}\n                            animate={{ opacity: 1, scale: 1 }}\n                            transition={{ ...layoutT, opacity: { duration: 0.12 } }}\n                            className={cn(\n                              \"absolute inset-x-0 z-[4] flex flex-col overflow-hidden rounded-md px-2 py-1 text-[11.5px] leading-tight\",\n                              draft.phase === \"drag\" ? \"bg-fg text-frame shadow-pop\" : \"border border-dashed border-fg-3 bg-raised text-fg\",\n                            )}\n                            style={{ top: draft.start * pxPerMin, height: Math.max(18, (draft.end - draft.start) * pxPerMin) - 1 }}\n                          >\n                            <span className={cn(\"truncate font-medium\", draft.phase === \"name\" && !draft.title && \"text-fg-3\")}>{draft.title || L.newEvent}</span>\n                            {(draft.end - draft.start) * pxPerMin >= 34 && (\n                              <span className={cn(\"truncate tabular\", draft.phase === \"drag\" ? \"text-frame/70\" : \"text-fg-3\")}>{compactRange(at(d, draft.start), at(d, draft.end))}</span>\n                            )}\n                            {(draft.end - draft.start) * pxPerMin >= 50 && (\n                              <span className={cn(\"truncate tabular\", draft.phase === \"drag\" ? \"text-frame/70\" : \"text-fg-4\")}>{duration(draft.end - draft.start)}</span>\n                            )}\n                          </motion.div>\n                        )}\n                      </div>\n                      {i === todayIdx && nowMin !== null && (\n                        <div aria-hidden className=\"pointer-events-none absolute inset-x-0 z-[3] h-px bg-fg\" style={{ top: nowMin * pxPerMin }}>\n                          <span className=\"absolute -left-[4px] -top-[3.5px] size-2 rounded-full bg-fg\" />\n                        </div>\n                      )}\n                    </div>\n                  );\n                })}\n                {/* Faint line for the current time across the other days. */}\n                {todayIdx >= 0 && nowMin !== null && (\n                  <div aria-hidden className=\"pointer-events-none absolute inset-x-0 z-[2] h-px bg-fg/20\" style={{ top: nowMin * pxPerMin }} />\n                )}\n                {!readOnly && (\n                  <div\n                    ref={(el) => {\n                      if (el) hoverRef.current = el;\n                    }}\n                    aria-hidden\n                    data-show=\"false\"\n                    className=\"pointer-events-none absolute top-0 z-[1] flex items-start rounded-md border border-line-2 px-1.5 pt-0.5 opacity-0 transition-opacity duration-100 data-[show=true]:opacity-100 max-pointer-fine:hidden\"\n                    style={{ width: `calc(${100 / count}% - 12px)`, marginLeft: 2, height: defaultDuration * pxPerMin - 1 }}\n                  >\n                    <span className=\"text-[10.5px] text-fg-3 tabular\" />\n                  </div>\n                )}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </div>\n      </motion.div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n\n      {/* Event details */}\n      <Popover.Root open={panel.open} onOpenChange={(open) => !open && setPanel((p) => ({ ...p, open: false }))}>\n        <Popover.Portal>\n          <Popover.Positioner anchor={panel.anchor} side=\"right\" align=\"start\" sideOffset={6} collisionPadding={8} collisionBoundary={rootEl ?? undefined} className=\"z-(--z-popover)\">\n            <Popover.Popup finalFocus={returnFocus} className={popupClass}>\n              {panel.event && <EventDetails event={panel.event} fmt={fmt} timeRange={timeRange} readOnly={readOnly} />}\n            </Popover.Popup>\n          </Popover.Positioner>\n        </Popover.Portal>\n      </Popover.Root>\n\n      {/* Quick create */}\n      <Popover.Root open={draft?.phase === \"name\" && !!draftEl} onOpenChange={(open) => !open && setDraft(null)}>\n        <Popover.Portal>\n          <Popover.Positioner anchor={draftEl} side=\"right\" align=\"start\" sideOffset={6} collisionPadding={8} collisionBoundary={rootEl ?? undefined} className=\"z-(--z-popover)\">\n            <Popover.Popup finalFocus={returnFocus} className={popupClass}>\n              {draft && dayList && (\n                <form\n                  className=\"flex w-[272px] flex-col gap-2 p-3\"\n                  onSubmit={(e) => {\n                    e.preventDefault();\n                    commitDraft();\n                  }}\n                >\n                  <Popover.Title className=\"sr-only\">{L.newEvent}</Popover.Title>\n                  <input\n                    autoFocus\n                    value={draft.title}\n                    onChange={(e) => setDraft({ ...draft, title: e.target.value })}\n                    placeholder={L.titlePlaceholder}\n                    aria-label=\"Event title\"\n                    autoComplete=\"off\"\n                    enterKeyHint=\"done\"\n                    maxLength={120}\n                    className=\"h-8 w-full bg-transparent px-1 text-base font-medium tracking-[-0.015em] text-fg outline-none placeholder:font-normal placeholder:text-fg-4 sm:text-[15px]\"\n                  />\n                  <p className=\"flex items-center gap-2 px-1 text-[12.5px] text-fg-2\">\n                    <Clock size={14} className=\"shrink-0 text-fg-3\" />\n                    <span className=\"min-w-0 truncate tabular\">\n                      {fmt.short.format(dayList[draft.day])}, {timeRange(at(dayList[draft.day], draft.start), at(dayList[draft.day], draft.end))}\n                    </span>\n                    <span className=\"shrink-0 text-fg-3 tabular\">{duration(draft.end - draft.start)}</span>\n                  </p>\n                  <div className=\"mt-1 flex items-center justify-end gap-1.5\">\n                    <button type=\"button\" onClick={() => setDraft(null)} className={cn(btnBase, \"px-2.5 text-fg-2 hover:bg-hover hover:text-fg\")}>\n                      Cancel\n                    </button>\n                    <button type=\"submit\" disabled={!draft.title.trim()} className={cn(btnBase, \"bg-fg px-3 text-frame hover:bg-fg/90 disabled:opacity-40\")}>\n                      {L.create}\n                    </button>\n                  </div>\n                </form>\n              )}\n            </Popover.Popup>\n          </Popover.Positioner>\n        </Popover.Portal>\n      </Popover.Root>\n    </div>\n  );\n\n  function openEvent(event: CalendarEvent, el: HTMLElement) {\n    returnFocus.current = el;\n    setDraft(null);\n    setPanel({ open: true, event, anchor: el });\n    onEventClick?.(event);\n  }\n}\n\nconst popupClass = cn(\n  \"origin-[var(--transform-origin)] rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n  \"transition-[scale,opacity] duration-200 ease-out-expo data-ending-style:duration-150\",\n  \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n  \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n);\nconst btnBase = cn(\n  \"inline-flex h-7 items-center justify-center rounded-md text-[12.5px] font-medium select-none\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"transition-[background-color,border-color,color,scale,opacity] duration-150 ease-out active:scale-[0.97] active:duration-75 disabled:pointer-events-none\",\n);\n\nfunction NavButton({ label, children, ...rest }: React.ComponentProps<\"button\"> & { label: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      className={cn(\n        \"relative grid size-8 place-items-center rounded-md text-fg-2 select-none\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n\ntype LayoutT = { duration: number } | typeof spring.snappy;\n\nfunction EventBlock({\n  seg,\n  layoutId,\n  layoutT,\n  pxPerMin,\n  past,\n  readOnly,\n  label,\n  longLabel,\n  onOpen,\n  onKeyDown,\n}: {\n  seg: DaySegment;\n  layoutId: string;\n  layoutT: LayoutT;\n  pxPerMin: number;\n  past: boolean;\n  readOnly: boolean;\n  label: string;\n  longLabel: string;\n  onOpen: (el: HTMLElement) => void;\n  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;\n}) {\n  const e = seg.event;\n  const tone = e.tone ?? \"neutral\";\n  const height = Math.max(18, (seg.end - seg.start) * pxPerMin) - 1;\n  // Text follows the space: one line under 34px, title then time up to 50px, a two-line title above that.\n  const narrow = seg.cols > 1 && seg.span / seg.cols <= 0.5;\n  const mode = height < 34 ? \"inline\" : height < 50 || narrow ? \"stack\" : \"tall\";\n  const tall = mode !== \"inline\";\n  const tentative = e.status === \"tentative\";\n  const canceled = e.status === \"canceled\";\n  const resizable = !readOnly && !seg.continuesAfter && height >= 22;\n  return (\n    <motion.button\n      type=\"button\"\n      layoutId={layoutId}\n      transition={layoutT}\n      data-event-id={e.id}\n      data-cont={seg.continuesBefore || undefined}\n      aria-haspopup=\"dialog\"\n      aria-label={longLabel}\n      aria-description={readOnly || seg.continuesBefore ? undefined : \"Alt and arrow keys to move, add Shift to change the length\"}\n      onClick={(ev) => onOpen(ev.currentTarget)}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"group/ev absolute flex min-w-0 overflow-hidden rounded-md pl-2 pr-1 text-left leading-tight select-none [-webkit-tap-highlight-color:transparent]\",\n        \"outline-none focus-visible:z-[5] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"shadow-[0_0_0_1px_var(--raised)] transition-[scale,box-shadow,opacity] duration-150 active:scale-[0.985]\",\n        \"before:absolute before:inset-y-1 before:left-[3px] before:w-[2px] before:rounded-full\",\n        tentative ? \"border border-dashed border-fg-4 bg-raised before:bg-fg-4\" : cn(toneFill[tone], toneEdge[tone]),\n        !readOnly && !seg.continuesBefore && \"cursor-grab active:cursor-grabbing\",\n        tall ? \"flex-col py-1\" : \"items-center gap-1.5\",\n        past && \"opacity-60 hover:opacity-100\",\n        seg.continuesBefore && \"rounded-t-none\",\n        seg.continuesAfter && \"rounded-b-none\",\n        \"hover:shadow-[0_0_0_1px_var(--raised),inset_0_0_0_1px_var(--line-2)]\",\n      )}\n      style={{\n        top: seg.start * pxPerMin,\n        height,\n        left: `${(seg.col / seg.cols) * 100}%`,\n        width: `calc(${(seg.span / seg.cols) * 100}% - 2px)`,\n      }}\n    >\n      <motion.span\n        layout=\"position\"\n        transition={layoutT}\n        className={cn(\n          \"min-w-0 text-[11.5px] font-medium text-fg\",\n          canceled && \"text-fg-4 line-through\",\n          mode === \"tall\" ? \"w-full [display:-webkit-box] overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2]\" : mode === \"stack\" ? \"w-full truncate\" : \"min-w-[45%] shrink truncate\",\n        )}\n      >\n        {e.title}\n      </motion.span>\n      <motion.span layout=\"position\" transition={layoutT} className={cn(\"min-w-0 truncate text-[11px] text-fg-3 tabular\", !tall && \"shrink-[3]\", !tall && narrow && \"hidden\")}>\n        {tall ? label : label.split(/\\s[–-]\\s/)[0]}\n      </motion.span>\n      {resizable && (\n        <span data-resize aria-hidden className=\"absolute inset-x-0 bottom-0 flex h-2 cursor-ns-resize items-end justify-center pb-0.5 opacity-0 transition-opacity duration-150 group-hover/ev:opacity-100\">\n          <span className=\"h-[2px] w-4 rounded-full bg-fg-3\" />\n        </span>\n      )}\n    </motion.button>\n  );\n}\n\nfunction DragBlock({ event, layoutId, layoutT, top, height, label }: { event: CalendarEvent; layoutId: string; layoutT: LayoutT; top: number; height: number; label: string }) {\n  if (!event) return null;\n  const tone = event.tone ?? \"neutral\";\n  return (\n    <motion.div\n      layoutId={layoutId}\n      transition={layoutT}\n      aria-hidden\n      className={cn(\n        \"absolute inset-x-0 z-[6] flex cursor-grabbing flex-col overflow-hidden rounded-md py-1 pl-2 pr-1 leading-tight shadow-pop\",\n        \"before:absolute before:inset-y-1 before:left-[3px] before:w-[2px] before:rounded-full\",\n        toneFill[tone],\n        toneEdge[tone],\n        \"outline-1 -outline-offset-1 outline-solid outline-fg-4\",\n      )}\n      style={{ top, height }}\n    >\n      <span className=\"truncate text-[11.5px] font-medium text-fg\">{event.title}</span>\n      {height >= 30 && <span className=\"truncate text-[11px] font-medium text-fg-2 tabular\">{label}</span>}\n    </motion.div>\n  );\n}\n\ntype DetailFmt = { long: Intl.DateTimeFormat };\n\nfunction EventDetails({ event, fmt, timeRange, readOnly }: { event: CalendarEvent; fmt: DetailFmt; timeRange: (s: Date, e: Date) => string; readOnly: boolean }) {\n  const tone = event.tone ?? \"neutral\";\n  return (\n    <div className=\"relative flex w-[280px] flex-col gap-2.5 p-4 pr-10\">\n      <div className=\"flex items-start gap-2.5\">\n        <span aria-hidden className={cn(\"mt-[3px] h-3.5 w-[3px] shrink-0 rounded-full\", event.status === \"tentative\" ? \"border border-fg-3\" : { neutral: \"bg-fg-3\", info: \"bg-info\", success: \"bg-success\", warning: \"bg-warning\", danger: \"bg-danger\" }[tone])} />\n        <div className=\"min-w-0\">\n          <Popover.Title className={cn(\"text-[14px] font-medium leading-snug tracking-[-0.015em] text-fg [overflow-wrap:anywhere]\", event.status === \"canceled\" && \"text-fg-3 line-through\")}>{event.title}</Popover.Title>\n          <Popover.Description className=\"mt-0.5 text-[12.5px] text-fg-2 tabular\">\n            {fmt.long.format(event.start)}\n            <br />\n            {isAllDay(event) ? \"All day\" : timeRange(event.start, event.end)}\n          </Popover.Description>\n        </div>\n      </div>\n      {event.location && (\n        <p className=\"flex items-start gap-2 pl-[13px] text-[12.5px] text-fg-2\">\n          <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"mt-px shrink-0 text-fg-3\">\n            <path d=\"M8 14s4.5-4.1 4.5-7.75a4.5 4.5 0 0 0-9 0C3.5 9.9 8 14 8 14z\" />\n            <circle cx=\"8\" cy=\"6.25\" r=\"1.5\" />\n          </svg>\n          <span className=\"min-w-0 [overflow-wrap:anywhere]\">{event.location}</span>\n        </p>\n      )}\n      {(event.status === \"tentative\" || event.status === \"canceled\") && <p className=\"pl-[13px] text-[12.5px] text-fg-3\">{event.status === \"tentative\" ? \"Tentative\" : \"Canceled\"}</p>}\n      {event.description && <p className=\"line-clamp-3 pl-[13px] text-[12.5px] leading-relaxed text-fg-3\">{event.description}</p>}\n      {!readOnly && !isAllDay(event) && <p className=\"pl-[13px] text-[11.5px] text-fg-4\">Drag to move or resize</p>}\n      <Popover.Close\n        aria-label=\"Close\"\n        className=\"absolute right-2 top-2 grid size-7 place-items-center rounded-md text-fg-3 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 active:scale-[0.92] before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\"\n      >\n        <X size={14} />\n      </Popover.Close>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/week-grid"
}
