Skip to content

FullCalendar — live demo ​

This is the real @rozie-ui/fullcalendar-vue package running on this page (VitePress is itself a Vue app). Click the events, drag the toolbar, switch views — then use the controls below to drive the imperative handle. The same FullCalendar component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.

The active view is two-way bound with v-model:view — the readout updates whether you click the calendar's own toolbar or the Month / Week buttons above, which drive the imperative handle (changeView). The Prev / Today / Next buttons call the prev, today, and next handle verbs, and the current month/week label is read back off the raw Calendar instance via getApi(). See the full API for the complete prop/event/handle surface.

What ships for each framework ​

You author the component once as a .rozie file:

html
<!--
  FullCalendar.rozie — data-bound port of FullCalendar (fullcalendar/fullcalendar).

  FullCalendar ships all four official wrappers (@fullcalendar/react,
  @fullcalendar/vue3, @fullcalendar/angular, @fullcalendar/svelte) —
  each one wraps the same vanilla-JS Calendar engine. The perfect
  "5 wrappers, 1 source" leverage story. ONE Rozie file covers
  six frameworks.

  Two-way binds the current view name so consumers can react to /
  drive view switches:

    <FullCalendar
      r-model:view="$data.view"
      :events="$data.events"
      @event-click="onEventClick"
      @date-click="onDateClick"
    />

  FullCalendar v6 auto-injects its CSS — consumers do NOT need a manual
  stylesheet import. The .rozie file's <style> is scoped so it carries
  only the wrapper's layout box, not the global .fc-* selectors.

  Why this is a worthwhile demo:
    - NEW domain: calendar grids with Date objects (not strings)
    - NEW pattern: object spread inside an engine method call
      (`cal.addEvent({ ...info, id: nextId(), color: $props.defaultColor })`)
    - NEW pattern: template literals interpolating $data/$props in
      $script (event title formatting)
    - Multi-plugin import shape — @fullcalendar/core + per-view plugins
      via separate default imports
    - Structured-payload $emit: eventClick forwards { event, jsEvent, el } —
      heavier payload shape than prior examples
    - Rich $expose imperative handle: getApi + 15 navigation/mutation/read-back verbs
-->

<rozie name="FullCalendar" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>

<types>
// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type {
  Calendar, DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput,
  EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg,
  WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg,
} from '@fullcalendar/core'
export interface FullCalendarEventRef { id: string; title: string; start: Date | null; end: Date | null }
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick { event: FullCalendarEventRef; jsEvent: MouseEvent | KeyboardEvent; el: HTMLElement }
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer { event: FullCalendarEventRef; jsEvent: MouseEvent; el: HTMLElement }
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick { date: Date; dateStr: string; allDay: boolean; dayEl: HTMLElement; jsEvent: MouseEvent }
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop { event: FullCalendarEventRef; oldEvent: FullCalendarEventRef; delta: Duration; revert: () => void }
export interface FullCalendarSelection { start: Date; end: Date; startStr: string; endStr: string; allDay: boolean }
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize { event: FullCalendarEventRef; oldEvent: FullCalendarEventRef; startDelta: Duration; endDelta: Duration; revert: () => void }
export interface FullCalendarDatesSet { start: Date; end: Date; view: string }
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect { jsEvent: UIEvent | null }
export interface FullCalendarLoading { isLoading: boolean }
export interface FullCalendarEventsSet { events: FullCalendarEventRef[] }
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg { text: string; view: ViewApi }
export type {
  Calendar, DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg,
  SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg,
}
</types>

<props>
{
  events: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.',
    },
  },
  view: {
    type: String,
    default: 'dayGridMonth',
    model: true,
    docs: {
      description:
        "The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.",
      example: '<FullCalendar r-model:view="view" :events="events" />',
    },
  },
  weekends: {
    type: Boolean,
    default: true,
    docs: {
      description:
        'Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.',
    },
  },
  editable: {
    type: Boolean,
    default: true,
    docs: {
      description:
        'Allow events to be dragged and resized. Runtime-updatable via `setOption`.',
    },
  },
  selectable: {
    type: Boolean,
    default: true,
    docs: {
      description:
        'Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.',
    },
  },
  // The Lit emitter converts a Number+String union attribute
  // order-independently (quick 260930-814): `height="600"` arrives as the
  // number 600 and `height="auto"` as the string 'auto'. normalizeHeight
  // below still turns a purely numeric string into pixels for the targets
  // that pass a static attribute through as a string.
  height: {
    type: [String, Number],
    default: 480,
    docs: {
      description:
        "The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.",
    },
  },
  defaultColor: {
    type: String,
    default: '#3b82f6',
    docs: {
      description:
        'Fallback event color stamped onto events that omit their own `color`.',
    },
  },
  locale: {
    type: String,
    default: 'en',
    docs: {
      description:
        'FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.',
    },
  },
  firstDay: {
    type: Number,
    default: null,
    docs: {
      description:
        'First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`\'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.',
    },
  },
  slotDuration: {
    type: String,
    default: '00:30:00',
    docs: {
      description:
        'Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.',
    },
  },
  nowIndicator: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.',
    },
  },
  headerToolbar: {
    type: Object,
    default: () => ({ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }),
    docs: {
      description:
        'The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.',
    },
  },
  // :options passthrough — arbitrary FullCalendar options/callbacks the curated
  // surface doesn't special-case (businessHours, dayMaxEvents, *DidMount hooks…).
  // Spread FIRST in the opts object so explicit curated props/events/slots win
  // on key collision (the curated surface is primary; :options fills gaps).
  options: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        'Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.',
    },
  },
}
</props>

<emits>
{
  eventClick: { payload: 'FullCalendarEventClick', docs: { description: "Fired when a calendar event is clicked, or activated with Enter/Space (then `jsEvent` is a `KeyboardEvent`). `el` is the clicked event's DOM element (use it as the anchor for a popover or tooltip)." } },
  dateClick: { payload: 'FullCalendarDateClick', docs: { description: 'Fired when an empty date/time cell is clicked. `dayEl` is the clicked day cell (an anchor for a popover) and `jsEvent` the click.' } },
  eventDrop: { payload: 'FullCalendarEventDrop', docs: { description: 'Fired after an event is dragged to a new date/time. Call `revert()` to reject the move; `oldEvent` is the event before it.' } },
  select: { payload: 'FullCalendarSelection', docs: { description: 'Fired when a date/time range is selected by drag (requires `selectable`).' } },
  eventResize: { payload: 'FullCalendarEventResize', docs: { description: 'Fired after an event is resized by dragging its edge (requires `editable`). Call `revert()` to reject the resize; `oldEvent` is the event before it.' } },
  datesSet: { payload: 'FullCalendarDatesSet', docs: { description: 'Fired whenever the visible date range changes (navigation or view switch). `view` is the active view type string.' } },
  eventMouseEnter: { payload: 'FullCalendarEventPointer', docs: { description: 'Fired when the pointer enters a calendar event (payload mirrors `eventClick`, with `jsEvent` always a `MouseEvent`).' } },
  eventMouseLeave: { payload: 'FullCalendarEventPointer', docs: { description: 'Fired when the pointer leaves a calendar event (payload mirrors `eventMouseEnter`).' } },
  unselect: { payload: 'FullCalendarUnselect', docs: { description: 'Fired when a previously selected date/time range is cleared. `jsEvent` is the pointer/touch `UIEvent`, or `null` when it was cleared programmatically (e.g. `clearSelection`).' } },
  loading: { payload: 'FullCalendarLoading', docs: { description: 'Fired when the calendar begins or finishes loading events. It fires only while FullCalendar fetches an event source itself (a URL/JSON feed or a function source); with only the `events` array bound it never fires (see Gotchas).' } },
  eventsSet: { payload: 'FullCalendarEventsSet', docs: { description: 'Fired after the set of rendered events changes — the normalized current event set, for persistence/sync consumers.' } },
}
</emits>

<script>
import { Calendar } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'

let instance = null
let suppressViewSync = false
// The event source the `events` prop owns. Reconciling replaces only this
// source, so events from `options.eventSources` or the `addEvent` verb survive.
let eventsSource = null
// Keys the wrapper itself sets (curated props, wrapped callbacks, filled
// *Content slots). `:options` never overrides them, at mount or at runtime.
let curatedKeys = new Set()
// The `:options` values last handed to FullCalendar, per key, so the runtime
// reconcile only calls setOption for keys whose value actually changed.
let appliedOptions = new Map()

const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin]

// Mirrors the `height` prop default.
const DEFAULT_HEIGHT = 480

// A purely numeric string height ('600') means pixels. Needed because a static
// Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
// the height prop) would otherwise regress `height="600"`, which the old Number
// converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
// passes through. An empty string, null/undefined, or a non-positive number
// falls back to the default so every target sizes the calendar the same way.
const normalizeHeight = (h) => {
  let v = h
  if (typeof v === 'string') {
    const t = v.trim()
    if (t === '') return DEFAULT_HEIGHT
    if (/^\d+(\.\d+)?$/.test(t)) v = Number(t)
    else return t
  }
  if (typeof v !== 'number' || !(v > 0)) return DEFAULT_HEIGHT
  return v
}

const normalizeEvent = (e) => {
  // Object spread — common reconcile shape: pass user props through, normalize
  // the title to a string WITHOUT inventing text (internal ids must never
  // render), and honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || '',
    color: e.color || $props.defaultColor,
  }
}

// The normalized event ref every payload carries.
const eventRef = (e) => ({ id: e.id, title: e.title, start: e.start, end: e.end })

// Structural equality for plain option values (arrays and plain objects by
// content, everything else — functions included — by identity). Lets an inline
// `:options` literal re-created on every parent render stay a no-op.
const sameOptionValue = (a, b) => {
  if (a === b) return true
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false
    for (let i = 0; i < a.length; i++) if (!sameOptionValue(a[i], b[i])) return false
    return true
  }
  const isPlain = (o) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null)
  if (isPlain(a) && isPlain(b)) {
    const ka = Object.keys(a)
    if (ka.length !== Object.keys(b).length) return false
    for (const k of ka) if (!(k in b) || !sameOptionValue(a[k], b[k])) return false
    return true
  }
  return false
}

$onMount(() => {
  // The curated config — every key the wrapper owns. The `:options`
  // passthrough below fills only the gaps (curated keys always win).
  //
  // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
  // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
  // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
  // interaction) with any consumer-added plugins. This makes the wrapper
  // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
  // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
  // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
  // consumer re-passing a default is harmless.
  // A null-let (typeNeutralize → `any` in every leaf): keys are added below
  // (`firstDay`, the filled *Content slots), which a strict object-literal type
  // would reject (TS2339).
  let curated = null
  curated = {
    plugins:       [...PLUGINS, ...($props.options?.plugins ?? [])],
    initialView:   $props.view,
    weekends:      $props.weekends,
    editable:      $props.editable,
    selectable:    $props.selectable,
    height:        normalizeHeight($props.height),
    locale:        $props.locale,
    slotDuration:  $props.slotDuration,
    nowIndicator:  $props.nowIndicator,
    // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
    // toolbar; the built-in default lives in the `headerToolbar` prop default.
    headerToolbar: $props.headerToolbar,
    eventClick: (info) => {
      $emit('eventClick', { event: eventRef(info.event), jsEvent: info.jsEvent, el: info.el })
    },
    dateClick: (info) => {
      $emit('dateClick', { date: info.date, dateStr: info.dateStr, allDay: info.allDay, dayEl: info.dayEl, jsEvent: info.jsEvent })
    },
    eventDrop: (info) => {
      $emit('eventDrop', { event: eventRef(info.event), oldEvent: eventRef(info.oldEvent), delta: info.delta, revert: info.revert })
    },
    select: (info) => {
      $emit('select', { start: info.start, end: info.end, startStr: info.startStr, endStr: info.endStr, allDay: info.allDay })
    },
    eventResize: (info) => {
      $emit('eventResize', {
        event:      eventRef(info.event),
        oldEvent:   eventRef(info.oldEvent),
        startDelta: info.startDelta,
        endDelta:   info.endDelta,
        revert:     info.revert,
      })
    },
    datesSet: (info) => {
      $emit('datesSet', { start: info.start, end: info.end, view: info.view.type })
    },
    eventMouseEnter: (info) => {
      $emit('eventMouseEnter', { event: eventRef(info.event), jsEvent: info.jsEvent, el: info.el })
    },
    eventMouseLeave: (info) => {
      $emit('eventMouseLeave', { event: eventRef(info.event), jsEvent: info.jsEvent, el: info.el })
    },
    unselect: (info) => {
      $emit('unselect', { jsEvent: info.jsEvent })
    },
    loading: (isLoading) => {
      // FullCalendar's `loading` callback receives a bare boolean (not an info
      // object) — normalize to the structured `{ isLoading }` payload shape.
      $emit('loading', { isLoading })
    },
    eventsSet: (events) => {
      // `eventsSet` receives the array of current EventApi objects — map each to
      // the normalized floor shape for persistence/sync consumers.
      $emit('eventsSet', { events: events.map(eventRef) })
    },
    viewDidMount: (info) => {
      // viewDidMount fires both on initial mount AND on changeView calls.
      // Same round-trip guard pattern as Flatpickr / LeafletMap.
      if (suppressViewSync) { suppressViewSync = false; return }
      if (info.view.type !== $props.view) $model.view = info.view.type
    },
    eventDidMount: (info) => {
      // Every event is focusable (the wrapper always handles eventClick), so an
      // untitled one needs an accessible name. A consumer's own
      // `options.eventDidMount` still runs, read live so updates apply.
      if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
        info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event')
      }
      const own = $props.options?.eventDidMount
      if (typeof own === 'function') own(info)
    },
  }
  // Unset (null) keeps the locale's own first day. Never pass the key empty:
  // FullCalendar's Number refiner would turn `undefined` into NaN.
  if (typeof $props.firstDay === 'number') curated.firstDay = $props.firstDay

  // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
  // slot, route every cell render through it. The portal helper mounts the
  // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
  // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
  // handle is returned to FullCalendar so it cleans up the mounted tree when
  // the cell is removed. Consumers that don't fill the slot get FullCalendar's
  // default rendering (title text) — guarded by `$slots.event`.
  if ($slots.event) {
    curated.eventContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.event(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  // The 9 remaining *Content portal-slots — wired identically to `event`, one
  // per FullCalendar per-cell content hook. Each guarded by its own slot so
  // unfilled slots keep FullCalendar's default rendering (or an
  // `options.*Content` passthrough). (10 portal-slots total counting `event`
  // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
  // hooks, and noEventsContent is the list-view "no events" hook — inert
  // unless the consumer engages @fullcalendar/list via :options.plugins.)
  //
  // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
  // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
  // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
  // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
  // unifies snippets and props into one `$props` namespace.
  if ($slots.dayCell) {
    curated.dayCellContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.dayCell(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.dayHeader) {
    curated.dayHeaderContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.dayHeader(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.slotLabel) {
    curated.slotLabelContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.slotLabel(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.weekNumber) {
    curated.weekNumberContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.weekNumber(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.nowIndicatorContent) {
    curated.nowIndicatorContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.nowIndicatorContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.moreLink) {
    curated.moreLinkContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.moreLink(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.allDayContent) {
    curated.allDayContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.allDayContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.slotLaneContent) {
    curated.slotLaneContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.slotLaneContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  // noEventsContent — the list-view "no events to display" hook. Pre-declared
  // and wired like the other 9 *Content slots, but INERT unless the consumer
  // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
  // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
  // the bundled-only plugin set there is no list view, so this hook never fires
  // — by design, documented, zero bundle cost.
  if ($slots.noEventsContent) {
    curated.noEventsContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.noEventsContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }

  // `events` is curated too: the prop owns its own event source (below), so an
  // `options.events` is ignored, exactly as before.
  curatedKeys = new Set([...Object.keys(curated), 'events'])
  const passthrough = Object.fromEntries(
    Object.entries($props.options ?? {}).filter(([k]) => !curatedKeys.has(k)),
  )
  appliedOptions = new Map(Object.entries(passthrough))

  instance = new Calendar($el, { ...passthrough, ...curated })
  eventsSource = instance.addEventSource($props.events.map(normalizeEvent))
  instance.render()
  return () => {
    instance?.destroy()
    instance = null
    eventsSource = null
  }
})

// Reconcile events without remounting: replace only the source this prop
// owns, so events from `options.eventSources` or the `addEvent` verb survive.
$watch(() => $props.events, (v) => {
  if (!instance) return
  eventsSource?.remove()
  eventsSource = instance.addEventSource((v ?? []).map(normalizeEvent))
})

$watch(() => $props.view, (v) => {
  if (!instance || !v) return
  if (v === instance.view.type) return
  suppressViewSync = true
  instance.changeView(v)
})

$watch(() => $props.weekends,   (v) => instance?.setOption('weekends',   v))
$watch(() => $props.editable,   (v) => instance?.setOption('editable',   v))
$watch(() => $props.selectable, (v) => instance?.setOption('selectable', v))
$watch(() => $props.height,     (v) => instance?.setOption('height',     normalizeHeight(v)))
$watch(() => $props.locale,        (v) => instance?.setOption('locale',        v))
$watch(() => $props.firstDay, (v) => {
  if (!instance || typeof v !== 'number') return
  curatedKeys.add('firstDay')
  instance.setOption('firstDay', v)
})
$watch(() => $props.slotDuration,  (v) => instance?.setOption('slotDuration',  v))
$watch(() => $props.nowIndicator,  (v) => instance?.setOption('nowIndicator',  v))
$watch(() => $props.headerToolbar, (v) => instance?.setOption('headerToolbar', v))

// :options runtime reconcile — apply each changed passthrough key through
// setOption. Curated keys are skipped, and an unchanged value (including an
// equal inline literal re-created by a parent render) is a no-op, so a
// re-render never refetches `eventSources` or replaces a wrapped callback.
// Documented limitation: removing a key from the object does NOT reset that
// option (setOption-per-key has no "unset"); consumers needing full control
// (including option removal) use getApi() and drive the Calendar directly.
$watch(() => $props.options, (v) => {
  if (!instance || !v) return
  for (const k in v) {
    if (curatedKeys.has(k)) continue
    if (sameOptionValue(appliedOptions.get(k), v[k])) continue
    appliedOptions.set(k, v[k])
    instance.setOption(k, v[k])
  }
})

// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and again after
// unmount (destroyed and cleared) — callers handle the null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() { return instance }
function changeView(...a) { return instance?.changeView(...a) }
// Normalized like the `events` prop (title, defaultColor).
function addEvent(event, source) { return instance?.addEvent(normalizeEvent(event), source) }
function removeEvent(id)  { instance?.getEventById(id)?.remove() }
function today()          { instance?.today() }
function prev()           { instance?.prev() }
function next()           { instance?.next() }
function gotoDate(...a)   { instance?.gotoDate(...a) }
function getDate()        { return instance ? instance.getDate() : null }
function getEvents()      { return instance ? instance.getEvents() : [] }
function scrollToTime(...a) { instance?.scrollToTime(...a) }
function updateSize()     { instance?.updateSize() }
function prevYear()       { instance?.prevYear() }
function nextYear()       { instance?.nextYear() }
function selectRange(...a) { instance?.select(...a) }
function clearSelection() { instance?.unselect() }

$expose({
  getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate,
  getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear,
  selectRange, clearSelection,
}, {
  getApi: '() => Calendar | null',
  changeView: '(viewType: string, dateOrRange?: DateRangeInput | DateInput) => void',
  addEvent: '(event: EventInput, source?: EventSourceApi | string | boolean) => EventApi | null | undefined',
  removeEvent: '(id: string) => void',
  today: '() => void',
  prev: '() => void',
  next: '() => void',
  gotoDate: '(date: DateInput) => void',
  getDate: '() => Date | null',
  getEvents: '() => EventApi[]',
  scrollToTime: '(time: DurationInput) => void',
  updateSize: '() => void',
  prevYear: '() => void',
  nextYear: '() => void',
  selectRange: '(dateOrSpan: DateInput | DateSpanInput, end?: DateInput) => void',
  clearSelection: '() => void',
})
</script>

<template>
<div class="rozie-fullcalendar" />
<!--
  Portal-slot primitive (Spike 003). The `event` slot is declared but NOT
  rendered in the template — per-target template emitters skip it. It exists
  only to declare the consumer-facing render-prop / scoped-slot / contentChild
  shape (per target). The wrapper invokes the slot from script via
  $portals.event(node, { arg }) inside FullCalendar's `eventContent` callback;
  the portal helper mounts the consumer's fragment into the engine-owned node
  and returns a dispose handle the engine calls on cell teardown.
-->
<slot name="event" portal :params="['arg']" :param-types="{ arg: 'EventContentArg' }" />
<slot name="dayCell" portal :params="['arg']" :param-types="{ arg: 'DayCellContentArg' }" />
<slot name="dayHeader" portal :params="['arg']" :param-types="{ arg: 'DayHeaderContentArg' }" />
<slot name="slotLabel" portal :params="['arg']" :param-types="{ arg: 'SlotLabelContentArg' }" />
<slot name="weekNumber" portal :params="['arg']" :param-types="{ arg: 'WeekNumberContentArg' }" />
<slot name="nowIndicatorContent" portal :params="['arg']" :param-types="{ arg: 'NowIndicatorContentArg' }" />
<slot name="moreLink" portal :params="['arg']" :param-types="{ arg: 'MoreLinkContentArg' }" />
<slot name="allDayContent" portal :params="['arg']" :param-types="{ arg: 'AllDayContentArg' }" />
<slot name="slotLaneContent" portal :params="['arg']" :param-types="{ arg: 'SlotLaneContentArg' }" />
<slot name="noEventsContent" portal :params="['arg']" :param-types="{ arg: 'FullCalendarNoEventsContentArg' }" />
</template>

<style>
.rozie-fullcalendar {
  width: 100%;
  font-size: 0.875rem;
}
</style>

</rozie>

…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/fullcalendar-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { useControllableState } from '@rozie/runtime-react';
import './FullCalendar.css';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';

interface EventCtx { arg: EventContentArg; }

interface DayCellCtx { arg: DayCellContentArg; }

interface DayHeaderCtx { arg: DayHeaderContentArg; }

interface SlotLabelCtx { arg: SlotLabelContentArg; }

interface WeekNumberCtx { arg: WeekNumberContentArg; }

interface NowIndicatorContentCtx { arg: NowIndicatorContentArg; }

interface MoreLinkCtx { arg: MoreLinkContentArg; }

interface AllDayContentCtx { arg: AllDayContentArg; }

interface SlotLaneContentCtx { arg: SlotLaneContentArg; }

interface NoEventsContentCtx { arg: FullCalendarNoEventsContentArg; }

interface FullCalendarProps {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
   */
  events?: any[];
  /**
   * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
   * @example
   * <FullCalendar view={view} onViewChange={setView} events={events} />
   */
  view?: string;
  defaultView?: string;
  onViewChange?: (view: string) => void;
  /**
   * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
   */
  weekends?: boolean;
  /**
   * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
   */
  editable?: boolean;
  /**
   * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
   */
  selectable?: boolean;
  /**
   * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
   */
  height?: string | number;
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  defaultColor?: string;
  /**
   * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
   */
  locale?: string;
  /**
   * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
   */
  firstDay?: (number) | null;
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  slotDuration?: string;
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  nowIndicator?: boolean;
  /**
   * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
   */
  headerToolbar?: Record<string, any>;
  /**
   * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
   */
  options?: Record<string, any>;
  onEventClick?: (payload: FullCalendarEventClick) => void;
  onDateClick?: (payload: FullCalendarDateClick) => void;
  onEventDrop?: (payload: FullCalendarEventDrop) => void;
  onSelect?: (payload: FullCalendarSelection) => void;
  onEventResize?: (payload: FullCalendarEventResize) => void;
  onDatesSet?: (payload: FullCalendarDatesSet) => void;
  onEventMouseEnter?: (payload: FullCalendarEventPointer) => void;
  onEventMouseLeave?: (payload: FullCalendarEventPointer) => void;
  onUnselect?: (payload: FullCalendarUnselect) => void;
  onLoading?: (payload: FullCalendarLoading) => void;
  onEventsSet?: (payload: FullCalendarEventsSet) => void;
  renderEvent?: (ctx: EventCtx) => ReactNode;
  renderDayCell?: (ctx: DayCellCtx) => ReactNode;
  renderDayHeader?: (ctx: DayHeaderCtx) => ReactNode;
  renderSlotLabel?: (ctx: SlotLabelCtx) => ReactNode;
  renderWeekNumber?: (ctx: WeekNumberCtx) => ReactNode;
  renderNowIndicatorContent?: (ctx: NowIndicatorContentCtx) => ReactNode;
  renderMoreLink?: (ctx: MoreLinkCtx) => ReactNode;
  renderAllDayContent?: (ctx: AllDayContentCtx) => ReactNode;
  renderSlotLaneContent?: (ctx: SlotLaneContentCtx) => ReactNode;
  renderNoEventsContent?: (ctx: NoEventsContentCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface FullCalendarHandle {
  getApi: () => Calendar | null;
  changeView: (viewType: string, dateOrRange?: DateRangeInput | DateInput) => void;
  addEvent: (event: EventInput, source?: EventSourceApi | string | boolean) => EventApi | null | undefined;
  removeEvent: (id: string) => void;
  today: () => void;
  prev: () => void;
  next: () => void;
  gotoDate: (date: DateInput) => void;
  getDate: () => Date | null;
  getEvents: () => EventApi[];
  scrollToTime: (time: DurationInput) => void;
  updateSize: () => void;
  prevYear: () => void;
  nextYear: () => void;
  selectRange: (dateOrSpan: DateInput | DateSpanInput, end?: DateInput) => void;
  clearSelection: () => void;
}

const FullCalendar = forwardRef<FullCalendarHandle, FullCalendarProps>(function FullCalendar(_props: FullCalendarProps, ref): JSX.Element {
  const portalRoots = useRef<Set<Root>>(new Set());
  const __defaultEvents = useState(() => (() => [])())[0];
  const __defaultHeaderToolbar = useState(() => (() => ({
    left: 'prev,next today',
    center: 'title',
    right: 'dayGridMonth,timeGridWeek,timeGridDay'
  }))())[0];
  const __defaultOptions = useState(() => (() => ({}))())[0];
  const props: Omit<FullCalendarProps, 'events' | 'weekends' | 'editable' | 'selectable' | 'height' | 'defaultColor' | 'locale' | 'firstDay' | 'slotDuration' | 'nowIndicator' | 'headerToolbar' | 'options'> & { events: any[]; weekends: boolean; editable: boolean; selectable: boolean; height: string | number; defaultColor: string; locale: string; firstDay: (number) | null; slotDuration: string; nowIndicator: boolean; headerToolbar: Record<string, any>; options: Record<string, any> } = {
    ..._props,
    events: _props.events ?? __defaultEvents,
    weekends: _props.weekends ?? true,
    editable: _props.editable ?? true,
    selectable: _props.selectable ?? true,
    height: _props.height ?? 480,
    defaultColor: _props.defaultColor ?? '#3b82f6',
    locale: _props.locale ?? 'en',
    firstDay: _props.firstDay ?? null,
    slotDuration: _props.slotDuration ?? '00:30:00',
    nowIndicator: _props.nowIndicator ?? false,
    headerToolbar: _props.headerToolbar ?? __defaultHeaderToolbar,
    options: _props.options ?? __defaultOptions,
  };
  const _renderEventRef = useRef(props.renderEvent);
  _renderEventRef.current = props.renderEvent;
  const _renderDayCellRef = useRef(props.renderDayCell);
  _renderDayCellRef.current = props.renderDayCell;
  const _renderDayHeaderRef = useRef(props.renderDayHeader);
  _renderDayHeaderRef.current = props.renderDayHeader;
  const _renderSlotLabelRef = useRef(props.renderSlotLabel);
  _renderSlotLabelRef.current = props.renderSlotLabel;
  const _renderWeekNumberRef = useRef(props.renderWeekNumber);
  _renderWeekNumberRef.current = props.renderWeekNumber;
  const _renderNowIndicatorContentRef = useRef(props.renderNowIndicatorContent);
  _renderNowIndicatorContentRef.current = props.renderNowIndicatorContent;
  const _renderMoreLinkRef = useRef(props.renderMoreLink);
  _renderMoreLinkRef.current = props.renderMoreLink;
  const _renderAllDayContentRef = useRef(props.renderAllDayContent);
  _renderAllDayContentRef.current = props.renderAllDayContent;
  const _renderSlotLaneContentRef = useRef(props.renderSlotLaneContent);
  _renderSlotLaneContentRef.current = props.renderSlotLaneContent;
  const _renderNoEventsContentRef = useRef(props.renderNoEventsContent);
  _renderNoEventsContentRef.current = props.renderNoEventsContent;
  const portals = {
    event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
      const slot = _renderEventRef.current ?? props.slots?.['event'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal event { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-event', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
      const slot = _renderDayCellRef.current ?? props.slots?.['dayCell'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal dayCell { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-dayCell', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
      const slot = _renderDayHeaderRef.current ?? props.slots?.['dayHeader'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal dayHeader { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
      const slot = _renderSlotLabelRef.current ?? props.slots?.['slotLabel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal slotLabel { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
      const slot = _renderWeekNumberRef.current ?? props.slots?.['weekNumber'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal weekNumber { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
      const slot = _renderNowIndicatorContentRef.current ?? props.slots?.['nowIndicatorContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal nowIndicatorContent { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
      const slot = _renderMoreLinkRef.current ?? props.slots?.['moreLink'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal moreLink { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-moreLink', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
      const slot = _renderAllDayContentRef.current ?? props.slots?.['allDayContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal allDayContent { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
      const slot = _renderSlotLaneContentRef.current ?? props.slots?.['slotLaneContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal slotLaneContent { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
      const slot = _renderNoEventsContentRef.current ?? props.slots?.['noEventsContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal noEventsContent { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
  };
  const suppressViewSync = useRef(false);
  const curatedKeys = useRef(new Set());
  const appliedOptions = useRef(new Map());
  const instance = useRef<any>(null);
  const eventsSource = useRef<any>(null);
  const [view, setView] = useControllableState({
    value: props.view,
    defaultValue: props.defaultView ?? 'dayGridMonth',
    onValueChange: props.onViewChange,
  });
  const _editableRef = useRef(props.editable);
  _editableRef.current = props.editable;
  const _eventsRef = useRef(props.events);
  _eventsRef.current = props.events;
  const _firstDayRef = useRef(props.firstDay);
  _firstDayRef.current = props.firstDay;
  const _headerToolbarRef = useRef(props.headerToolbar);
  _headerToolbarRef.current = props.headerToolbar;
  const _heightRef = useRef(props.height);
  _heightRef.current = props.height;
  const _localeRef = useRef(props.locale);
  _localeRef.current = props.locale;
  const _nowIndicatorRef = useRef(props.nowIndicator);
  _nowIndicatorRef.current = props.nowIndicator;
  const _onDateClickRef = useRef(props.onDateClick);
  _onDateClickRef.current = props.onDateClick;
  const _onDatesSetRef = useRef(props.onDatesSet);
  _onDatesSetRef.current = props.onDatesSet;
  const _onEventClickRef = useRef(props.onEventClick);
  _onEventClickRef.current = props.onEventClick;
  const _onEventDropRef = useRef(props.onEventDrop);
  _onEventDropRef.current = props.onEventDrop;
  const _onEventMouseEnterRef = useRef(props.onEventMouseEnter);
  _onEventMouseEnterRef.current = props.onEventMouseEnter;
  const _onEventMouseLeaveRef = useRef(props.onEventMouseLeave);
  _onEventMouseLeaveRef.current = props.onEventMouseLeave;
  const _onEventResizeRef = useRef(props.onEventResize);
  _onEventResizeRef.current = props.onEventResize;
  const _onEventsSetRef = useRef(props.onEventsSet);
  _onEventsSetRef.current = props.onEventsSet;
  const _onLoadingRef = useRef(props.onLoading);
  _onLoadingRef.current = props.onLoading;
  const _onSelectRef = useRef(props.onSelect);
  _onSelectRef.current = props.onSelect;
  const _onUnselectRef = useRef(props.onUnselect);
  _onUnselectRef.current = props.onUnselect;
  const _optionsRef = useRef(props.options);
  _optionsRef.current = props.options;
  const _selectableRef = useRef(props.selectable);
  _selectableRef.current = props.selectable;
  const _slotDurationRef = useRef(props.slotDuration);
  _slotDurationRef.current = props.slotDuration;
  const _weekendsRef = useRef(props.weekends);
  _weekendsRef.current = props.weekends;
  const _viewRef = useRef(view);
  _viewRef.current = view;
  const __rozieRoot = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);
  const _watch1First = useRef(true);
  const _watch2First = useRef(true);
  const _watch3First = useRef(true);
  const _watch4First = useRef(true);
  const _watch5First = useRef(true);
  const _watch6First = useRef(true);
  const _watch7First = useRef(true);
  const _watch8First = useRef(true);
  const _watch9First = useRef(true);
  const _watch10First = useRef(true);
  const _watch11First = useRef(true);

  // The `:options` values last handed to FullCalendar, per key, so the runtime
  // reconcile only calls setOption for keys whose value actually changed.
  // Keys the wrapper itself sets (curated props, wrapped callbacks, filled
  // *Content slots). `:options` never overrides them, at mount or at runtime.
  // The event source the `events` prop owns. Reconciling replaces only this
  // source, so events from `options.eventSources` or the `addEvent` verb survive.
  const PLUGINS = useMemo(() => [dayGridPlugin, timeGridPlugin, interactionPlugin], []);
  // Mirrors the `height` prop default.
  const DEFAULT_HEIGHT = useMemo(() => 480, []);
  // A purely numeric string height ('600') means pixels. Needed because a static
  // Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
  // the height prop) would otherwise regress `height="600"`, which the old Number
  // converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
  // passes through. An empty string, null/undefined, or a non-positive number
  // falls back to the default so every target sizes the calendar the same way.
  const normalizeHeight = useCallback((h: any) => {
    let v = h;
    if (typeof v === 'string') {
      const t = v.trim();
      if (t === '') return DEFAULT_HEIGHT;
      if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
    }
    if (typeof v !== 'number' || !(v > 0)) return DEFAULT_HEIGHT;
    return v;
  }, []);
  const normalizeEvent = useCallback((e: any) => {
    // Object spread — common reconcile shape: pass user props through, normalize
    // the title to a string WITHOUT inventing text (internal ids must never
    // render), and honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || '',
      color: e.color || props.defaultColor
    };
  }, [props.defaultColor]);
  // The normalized event ref every payload carries.
  const eventRef = useCallback((e: any) => ({
    id: e.id,
    title: e.title,
    start: e.start,
    end: e.end
  }), []);
  // Structural equality for plain option values (arrays and plain objects by
  // content, everything else — functions included — by identity). Lets an inline
  // `:options` literal re-created on every parent render stay a no-op.
  function sameOptionValue(a: any, b: any) {
    if (a === b) return true;
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      for (let i = 0; i < a.length; i++) if (!sameOptionValue(a[i], b[i])) return false;
      return true;
    }
    const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
    if (isPlain(a) && isPlain(b)) {
      const ka = Object.keys(a);
      if (ka.length !== Object.keys(b).length) return false;
      for (const k of ka as any) if (!(k in b) || !sameOptionValue(a[k], b[k])) return false;
      return true;
    }
    return false;
  }
  // Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets
  // (Vue defineExpose / React useImperativeHandle / Svelte instance export /
  // Angular+Lit public method / Solid callback ref). Each delegates to the
  // underlying Calendar instance, which is null before $onMount and again after
  // unmount (destroyed and cleared) — callers handle the null.
  //
  // Collision discipline (the load-bearing flatpickr lesson): no exposed name may
  // collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
  // datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
  // a declared prop. This is why the selection verbs are NAMED `selectRange`
  // (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
  // `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
  // is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
  //
  // Read-back gap closed: getDate (current anchor — the `view` model only carries
  // the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
  // event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
  // scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
  function getApi() {
    return instance.current;
  }
  function changeView(...a: any[]) {
    return instance.current?.changeView(...a);
  }
  // Normalized like the `events` prop (title, defaultColor).
  function addEvent(event: any, source: any) {
    return instance.current?.addEvent(normalizeEvent(event), source);
  }
  function removeEvent(id: any) {
    instance.current?.getEventById(id)?.remove();
  }
  function today() {
    instance.current?.today();
  }
  function prev() {
    instance.current?.prev();
  }
  function next() {
    instance.current?.next();
  }
  function gotoDate(...a: any[]) {
    instance.current?.gotoDate(...a);
  }
  function getDate() {
    return instance.current ? instance.current.getDate() : null;
  }
  function getEvents() {
    return instance.current ? instance.current.getEvents() : [];
  }
  function scrollToTime(...a: any[]) {
    instance.current?.scrollToTime(...a);
  }
  function updateSize() {
    instance.current?.updateSize();
  }
  function prevYear() {
    instance.current?.prevYear();
  }
  function nextYear() {
    instance.current?.nextYear();
  }
  function selectRange(...a: any[]) {
    instance.current?.select(...a);
  }
  function clearSelection() {
    instance.current?.unselect();
  }

  useEffect(() => {
    // The curated config — every key the wrapper owns. The `:options`
    // passthrough below fills only the gaps (curated keys always win).
    //
    // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
    // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
    // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
    // interaction) with any consumer-added plugins. This makes the wrapper
    // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
    // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
    // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
    // consumer re-passing a default is harmless.
    // A null-let (typeNeutralize → `any` in every leaf): keys are added below
    // (`firstDay`, the filled *Content slots), which a strict object-literal type
    // would reject (TS2339).
    let curated: any = null;
    curated = {
      plugins: [...PLUGINS, ...(_optionsRef.current?.plugins ?? [])],
      initialView: _viewRef.current,
      weekends: _weekendsRef.current,
      editable: _editableRef.current,
      selectable: _selectableRef.current,
      height: normalizeHeight(_heightRef.current),
      locale: _localeRef.current,
      slotDuration: _slotDurationRef.current,
      nowIndicator: _nowIndicatorRef.current,
      // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
      // toolbar; the built-in default lives in the `headerToolbar` prop default.
      headerToolbar: _headerToolbarRef.current,
      eventClick: (info: any) => {
        _onEventClickRef.current && _onEventClickRef.current({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      dateClick: (info: any) => {
        _onDateClickRef.current && _onDateClickRef.current({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay,
          dayEl: info.dayEl,
          jsEvent: info.jsEvent
        });
      },
      eventDrop: (info: any) => {
        _onEventDropRef.current && _onEventDropRef.current({
          event: eventRef(info.event),
          oldEvent: eventRef(info.oldEvent),
          delta: info.delta,
          revert: info.revert
        });
      },
      select: (info: any) => {
        _onSelectRef.current && _onSelectRef.current({
          start: info.start,
          end: info.end,
          startStr: info.startStr,
          endStr: info.endStr,
          allDay: info.allDay
        });
      },
      eventResize: (info: any) => {
        _onEventResizeRef.current && _onEventResizeRef.current({
          event: eventRef(info.event),
          oldEvent: eventRef(info.oldEvent),
          startDelta: info.startDelta,
          endDelta: info.endDelta,
          revert: info.revert
        });
      },
      datesSet: (info: any) => {
        _onDatesSetRef.current && _onDatesSetRef.current({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        _onEventMouseEnterRef.current && _onEventMouseEnterRef.current({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      eventMouseLeave: (info: any) => {
        _onEventMouseLeaveRef.current && _onEventMouseLeaveRef.current({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      unselect: (info: any) => {
        _onUnselectRef.current && _onUnselectRef.current({
          jsEvent: info.jsEvent
        });
      },
      loading: (isLoading: any) => {
        // FullCalendar's `loading` callback receives a bare boolean (not an info
        // object) — normalize to the structured `{ isLoading }` payload shape.
        _onLoadingRef.current && _onLoadingRef.current({
          isLoading
        });
      },
      eventsSet: (events: any) => {
        // `eventsSet` receives the array of current EventApi objects — map each to
        // the normalized floor shape for persistence/sync consumers.
        _onEventsSetRef.current && _onEventsSetRef.current({
          events: events.map(eventRef)
        });
      },
      viewDidMount: (info: any) => {
        // viewDidMount fires both on initial mount AND on changeView calls.
        // Same round-trip guard pattern as Flatpickr / LeafletMap.
        if (suppressViewSync.current) {
          suppressViewSync.current = false;
          return;
        }
        if (info.view.type !== _viewRef.current) setView(info.view.type);
      },
      eventDidMount: (info: any) => {
        // Every event is focusable (the wrapper always handles eventClick), so an
        // untitled one needs an accessible name. A consumer's own
        // `options.eventDidMount` still runs, read live so updates apply.
        if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
          info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
        }
        const own = _optionsRef.current?.eventDidMount;
        if (typeof own === 'function') own(info);
      }
    };
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    if (typeof _firstDayRef.current === 'number') curated.firstDay = _firstDayRef.current;

    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    if ((props.renderEvent ?? props.slots?.["event"])) {
      curated.eventContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.event(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    if ((props.renderDayCell ?? props.slots?.["dayCell"])) {
      curated.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderDayHeader ?? props.slots?.["dayHeader"])) {
      curated.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderSlotLabel ?? props.slots?.["slotLabel"])) {
      curated.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderWeekNumber ?? props.slots?.["weekNumber"])) {
      curated.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderNowIndicatorContent ?? props.slots?.["nowIndicatorContent"])) {
      curated.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderMoreLink ?? props.slots?.["moreLink"])) {
      curated.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderAllDayContent ?? props.slots?.["allDayContent"])) {
      curated.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderSlotLaneContent ?? props.slots?.["slotLaneContent"])) {
      curated.slotLaneContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLaneContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    if ((props.renderNoEventsContent ?? props.slots?.["noEventsContent"])) {
      curated.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }

    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    curatedKeys.current = new Set([...Object.keys(curated), 'events']);
    const passthrough = Object.fromEntries(Object.entries(_optionsRef.current ?? {}).filter(([k]: any) => !curatedKeys.current.has(k)));
    appliedOptions.current = new Map(Object.entries(passthrough));
    instance.current = new Calendar(__rozieRoot.current!, {
      ...passthrough,
      ...curated
    });
    eventsSource.current = instance.current.addEventSource(_eventsRef.current.map(normalizeEvent));
    instance.current.render();
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      instance.current?.destroy();
      instance.current = null;
      eventsSource.current = null;
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const v = props.events;
    if (!instance.current) return;
    eventsSource.current?.remove();
    eventsSource.current = instance.current.addEventSource((v ?? []).map(normalizeEvent));
  }, [props.events]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    const v = view;
    if (!instance.current || !v) return;
    if (v === instance.current.view.type) return;
    suppressViewSync.current = true;
    instance.current.changeView(v);
  }, [view]);
  useEffect(() => {
    if (_watch2First.current) { _watch2First.current = false; return; }
    const v = props.weekends;
    instance.current?.setOption('weekends', v);
  }, [props.weekends]);
  useEffect(() => {
    if (_watch3First.current) { _watch3First.current = false; return; }
    const v = props.editable;
    instance.current?.setOption('editable', v);
  }, [props.editable]);
  useEffect(() => {
    if (_watch4First.current) { _watch4First.current = false; return; }
    const v = props.selectable;
    instance.current?.setOption('selectable', v);
  }, [props.selectable]);
  useEffect(() => {
    if (_watch5First.current) { _watch5First.current = false; return; }
    const v = props.height;
    instance.current?.setOption('height', normalizeHeight(v));
  }, [props.height]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch6First.current) { _watch6First.current = false; return; }
    const v = props.locale;
    instance.current?.setOption('locale', v);
  }, [props.locale]);
  useEffect(() => {
    if (_watch7First.current) { _watch7First.current = false; return; }
    const v = props.firstDay;
    if (!instance.current || typeof v !== 'number') return;
    curatedKeys.current.add('firstDay');
    instance.current.setOption('firstDay', v);
  }, [props.firstDay]);
  useEffect(() => {
    if (_watch8First.current) { _watch8First.current = false; return; }
    const v = props.slotDuration;
    instance.current?.setOption('slotDuration', v);
  }, [props.slotDuration]);
  useEffect(() => {
    if (_watch9First.current) { _watch9First.current = false; return; }
    const v = props.nowIndicator;
    instance.current?.setOption('nowIndicator', v);
  }, [props.nowIndicator]);
  useEffect(() => {
    if (_watch10First.current) { _watch10First.current = false; return; }
    const v = props.headerToolbar;
    instance.current?.setOption('headerToolbar', v);
  }, [props.headerToolbar]);
  useEffect(() => {
    if (_watch11First.current) { _watch11First.current = false; return; }
    const v = props.options;
    if (!instance.current || !v) return;
    for (const k in v) {
      if (curatedKeys.current.has(k)) continue;
      if (sameOptionValue(appliedOptions.current.get(k), v[k])) continue;
      appliedOptions.current.set(k, v[k]);
      instance.current.setOption(k, v[k]);
    }
  }, [props.options]); // eslint-disable-line react-hooks/exhaustive-deps

  const _rozieExposeRef = useRef({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection });
  _rozieExposeRef.current = { getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection };
  useImperativeHandle(ref, () => ({ getApi: (...args: Parameters<typeof getApi>): ReturnType<typeof getApi> => _rozieExposeRef.current.getApi(...args), changeView: (...args: Parameters<typeof changeView>): ReturnType<typeof changeView> => _rozieExposeRef.current.changeView(...args), addEvent: (...args: Parameters<typeof addEvent>): ReturnType<typeof addEvent> => _rozieExposeRef.current.addEvent(...args), removeEvent: (...args: Parameters<typeof removeEvent>): ReturnType<typeof removeEvent> => _rozieExposeRef.current.removeEvent(...args), today: (...args: Parameters<typeof today>): ReturnType<typeof today> => _rozieExposeRef.current.today(...args), prev: (...args: Parameters<typeof prev>): ReturnType<typeof prev> => _rozieExposeRef.current.prev(...args), next: (...args: Parameters<typeof next>): ReturnType<typeof next> => _rozieExposeRef.current.next(...args), gotoDate: (...args: Parameters<typeof gotoDate>): ReturnType<typeof gotoDate> => _rozieExposeRef.current.gotoDate(...args), getDate: (...args: Parameters<typeof getDate>): ReturnType<typeof getDate> => _rozieExposeRef.current.getDate(...args), getEvents: (...args: Parameters<typeof getEvents>): ReturnType<typeof getEvents> => _rozieExposeRef.current.getEvents(...args), scrollToTime: (...args: Parameters<typeof scrollToTime>): ReturnType<typeof scrollToTime> => _rozieExposeRef.current.scrollToTime(...args), updateSize: (...args: Parameters<typeof updateSize>): ReturnType<typeof updateSize> => _rozieExposeRef.current.updateSize(...args), prevYear: (...args: Parameters<typeof prevYear>): ReturnType<typeof prevYear> => _rozieExposeRef.current.prevYear(...args), nextYear: (...args: Parameters<typeof nextYear>): ReturnType<typeof nextYear> => _rozieExposeRef.current.nextYear(...args), selectRange: (...args: Parameters<typeof selectRange>): ReturnType<typeof selectRange> => _rozieExposeRef.current.selectRange(...args), clearSelection: (...args: Parameters<typeof clearSelection>): ReturnType<typeof clearSelection> => _rozieExposeRef.current.clearSelection(...args) }), []);

  return (
    <>
    <div className={"rozie-fullcalendar"} ref={__rozieRoot} data-rozie-s-5589629a="" />











    </>
  );
});
export default FullCalendar;
vue
<template>

<div class="rozie-fullcalendar" ref="__rozieRootRef"></div>












</template>

<script lang="ts">
// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';

export interface FullCalendarHandle {
  getApi: () => Calendar | null;
  changeView: (viewType: string, dateOrRange?: DateRangeInput | DateInput) => void;
  addEvent: (event: EventInput, source?: EventSourceApi | string | boolean) => EventApi | null | undefined;
  removeEvent: (id: string) => void;
  today: () => void;
  prev: () => void;
  next: () => void;
  gotoDate: (date: DateInput) => void;
  getDate: () => Date | null;
  getEvents: () => EventApi[];
  scrollToTime: (time: DurationInput) => void;
  updateSize: () => void;
  prevYear: () => void;
  nextYear: () => void;
  selectRange: (dateOrSpan: DateInput | DateSpanInput, end?: DateInput) => void;
  clearSelection: () => void;
}
</script>

<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';

import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

defineOptions({ inheritAttrs: false });

const props = withDefaults(
  defineProps<{
    /**
     * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
     */
    events?: any[];
    /**
     * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
     */
    weekends?: boolean;
    /**
     * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
     */
    editable?: boolean;
    /**
     * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
     */
    selectable?: boolean;
    /**
     * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
     */
    height?: string | number;
    /**
     * Fallback event color stamped onto events that omit their own `color`.
     */
    defaultColor?: string;
    /**
     * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
     */
    locale?: string;
    /**
     * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
     */
    firstDay?: number | null;
    /**
     * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
     */
    slotDuration?: string;
    /**
     * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
     */
    nowIndicator?: boolean;
    /**
     * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
     */
    headerToolbar?: Record<string, any>;
    /**
     * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
     */
    options?: Record<string, any>;
  }>(),
  { events: () => [], weekends: true, editable: true, selectable: true, height: 480, defaultColor: '#3b82f6', locale: 'en', firstDay: null, slotDuration: '00:30:00', nowIndicator: false, headerToolbar: () => ({
  left: 'prev,next today',
  center: 'title',
  right: 'dayGridMonth,timeGridWeek,timeGridDay'
}), options: () => ({}) }
);

/**
 * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
 * @example
 * <FullCalendar v-model:view="view" :events="events" />
 */
const view = defineModel<string>('view', { default: 'dayGridMonth' });

const emit = defineEmits<{
  eventClick: [payload: FullCalendarEventClick];
  dateClick: [payload: FullCalendarDateClick];
  eventDrop: [payload: FullCalendarEventDrop];
  select: [payload: FullCalendarSelection];
  eventResize: [payload: FullCalendarEventResize];
  datesSet: [payload: FullCalendarDatesSet];
  eventMouseEnter: [payload: FullCalendarEventPointer];
  eventMouseLeave: [payload: FullCalendarEventPointer];
  unselect: [payload: FullCalendarUnselect];
  loading: [payload: FullCalendarLoading];
  eventsSet: [payload: FullCalendarEventsSet];
}>();

defineSlots<{
  event(props: { arg: EventContentArg }): any;
  dayCell(props: { arg: DayCellContentArg }): any;
  dayHeader(props: { arg: DayHeaderContentArg }): any;
  slotLabel(props: { arg: SlotLabelContentArg }): any;
  weekNumber(props: { arg: WeekNumberContentArg }): any;
  nowIndicatorContent(props: { arg: NowIndicatorContentArg }): any;
  moreLink(props: { arg: MoreLinkContentArg }): any;
  allDayContent(props: { arg: AllDayContentArg }): any;
  slotLaneContent(props: { arg: SlotLaneContentArg }): any;
  noEventsContent(props: { arg: FullCalendarNoEventsContentArg }): any;
}>();

const slots = useSlots();

const __rozieRootRef = ref<HTMLElement>();

const portalContainers = new Set<HTMLElement>();
const portals = {
  event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
    const slotFn = slots.event;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // event { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-event', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
    const slotFn = slots.dayCell;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // dayCell { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-dayCell', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
    const slotFn = slots.dayHeader;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // dayHeader { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
    const slotFn = slots.slotLabel;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // slotLabel { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
    const slotFn = slots.weekNumber;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // weekNumber { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
    const slotFn = slots.nowIndicatorContent;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // nowIndicatorContent { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
    const slotFn = slots.moreLink;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // moreLink { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-moreLink', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
    const slotFn = slots.allDayContent;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // allDayContent { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
    const slotFn = slots.slotLaneContent;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // slotLaneContent { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
    const slotFn = slots.noEventsContent;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // noEventsContent { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
};
onBeforeUnmount(() => {
  for (const container of portalContainers) render(null, container);
  portalContainers.clear();
});

let instance: any = null;
let suppressViewSync = false;
// The event source the `events` prop owns. Reconciling replaces only this
// source, so events from `options.eventSources` or the `addEvent` verb survive.
let eventsSource: any = null;
// Keys the wrapper itself sets (curated props, wrapped callbacks, filled
// *Content slots). `:options` never overrides them, at mount or at runtime.
let curatedKeys = new Set();
// The `:options` values last handed to FullCalendar, per key, so the runtime
// reconcile only calls setOption for keys whose value actually changed.
let appliedOptions = new Map();
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
// Mirrors the `height` prop default.
const DEFAULT_HEIGHT = 480;
// A purely numeric string height ('600') means pixels. Needed because a static
// Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
// the height prop) would otherwise regress `height="600"`, which the old Number
// converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
// passes through. An empty string, null/undefined, or a non-positive number
// falls back to the default so every target sizes the calendar the same way.
const normalizeHeight = (h: any) => {
  let v = h;
  if (typeof v === 'string') {
    const t = v.trim();
    if (t === '') return DEFAULT_HEIGHT;
    if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
  }
  if (typeof v !== 'number' || !(v > 0)) return DEFAULT_HEIGHT;
  return v;
};
const normalizeEvent = (e: any) => {
  // Object spread — common reconcile shape: pass user props through, normalize
  // the title to a string WITHOUT inventing text (internal ids must never
  // render), and honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || '',
    color: e.color || props.defaultColor
  };
};
// The normalized event ref every payload carries.
const eventRef = (e: any) => ({
  id: e.id,
  title: e.title,
  start: e.start,
  end: e.end
});
// Structural equality for plain option values (arrays and plain objects by
// content, everything else — functions included — by identity). Lets an inline
// `:options` literal re-created on every parent render stay a no-op.
const sameOptionValue = (a: any, b: any) => {
  if (a === b) return true;
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) if (!sameOptionValue(a[i], b[i])) return false;
    return true;
  }
  const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
  if (isPlain(a) && isPlain(b)) {
    const ka = Object.keys(a);
    if (ka.length !== Object.keys(b).length) return false;
    for (const k of ka as any) if (!(k in b) || !sameOptionValue(a[k], b[k])) return false;
    return true;
  }
  return false;
};
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and again after
// unmount (destroyed and cleared) — callers handle the null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() {
  return instance;
}
function changeView(...a: any[]) {
  return instance?.changeView(...a);
}
// Normalized like the `events` prop (title, defaultColor).
function addEvent(event: any, source: any) {
  return instance?.addEvent(normalizeEvent(event), source);
}
function removeEvent(id: any) {
  instance?.getEventById(id)?.remove();
}
function today() {
  instance?.today();
}
function prev() {
  instance?.prev();
}
function next() {
  instance?.next();
}
function gotoDate(...a: any[]) {
  instance?.gotoDate(...a);
}
function getDate() {
  return instance ? instance.getDate() : null;
}
function getEvents() {
  return instance ? instance.getEvents() : [];
}
function scrollToTime(...a: any[]) {
  instance?.scrollToTime(...a);
}
function updateSize() {
  instance?.updateSize();
}
function prevYear() {
  instance?.prevYear();
}
function nextYear() {
  instance?.nextYear();
}
function selectRange(...a: any[]) {
  instance?.select(...a);
}
function clearSelection() {
  instance?.unselect();
}

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  // The curated config — every key the wrapper owns. The `:options`
  // passthrough below fills only the gaps (curated keys always win).
  //
  // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
  // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
  // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
  // interaction) with any consumer-added plugins. This makes the wrapper
  // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
  // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
  // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
  // consumer re-passing a default is harmless.
  // A null-let (typeNeutralize → `any` in every leaf): keys are added below
  // (`firstDay`, the filled *Content slots), which a strict object-literal type
  // would reject (TS2339).
  let curated: any = null;
  curated = {
    plugins: [...PLUGINS, ...(props.options?.plugins ?? [])],
    initialView: view.value,
    weekends: props.weekends,
    editable: props.editable,
    selectable: props.selectable,
    height: normalizeHeight(props.height),
    locale: props.locale,
    slotDuration: props.slotDuration,
    nowIndicator: props.nowIndicator,
    // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
    // toolbar; the built-in default lives in the `headerToolbar` prop default.
    headerToolbar: props.headerToolbar,
    eventClick: (info: any) => {
      emit('eventClick', {
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    dateClick: (info: any) => {
      emit('dateClick', {
        date: info.date,
        dateStr: info.dateStr,
        allDay: info.allDay,
        dayEl: info.dayEl,
        jsEvent: info.jsEvent
      });
    },
    eventDrop: (info: any) => {
      emit('eventDrop', {
        event: eventRef(info.event),
        oldEvent: eventRef(info.oldEvent),
        delta: info.delta,
        revert: info.revert
      });
    },
    select: (info: any) => {
      emit('select', {
        start: info.start,
        end: info.end,
        startStr: info.startStr,
        endStr: info.endStr,
        allDay: info.allDay
      });
    },
    eventResize: (info: any) => {
      emit('eventResize', {
        event: eventRef(info.event),
        oldEvent: eventRef(info.oldEvent),
        startDelta: info.startDelta,
        endDelta: info.endDelta,
        revert: info.revert
      });
    },
    datesSet: (info: any) => {
      emit('datesSet', {
        start: info.start,
        end: info.end,
        view: info.view.type
      });
    },
    eventMouseEnter: (info: any) => {
      emit('eventMouseEnter', {
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    eventMouseLeave: (info: any) => {
      emit('eventMouseLeave', {
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    unselect: (info: any) => {
      emit('unselect', {
        jsEvent: info.jsEvent
      });
    },
    loading: (isLoading: any) => {
      // FullCalendar's `loading` callback receives a bare boolean (not an info
      // object) — normalize to the structured `{ isLoading }` payload shape.
      emit('loading', {
        isLoading
      });
    },
    eventsSet: (events: any) => {
      // `eventsSet` receives the array of current EventApi objects — map each to
      // the normalized floor shape for persistence/sync consumers.
      emit('eventsSet', {
        events: events.map(eventRef)
      });
    },
    viewDidMount: (info: any) => {
      // viewDidMount fires both on initial mount AND on changeView calls.
      // Same round-trip guard pattern as Flatpickr / LeafletMap.
      if (suppressViewSync) {
        suppressViewSync = false;
        return;
      }
      if (info.view.type !== view.value) view.value = info.view.type;
    },
    eventDidMount: (info: any) => {
      // Every event is focusable (the wrapper always handles eventClick), so an
      // untitled one needs an accessible name. A consumer's own
      // `options.eventDidMount` still runs, read live so updates apply.
      if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
        info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
      }
      const own = props.options?.eventDidMount;
      if (typeof own === 'function') own(info);
    }
  };
  // Unset (null) keeps the locale's own first day. Never pass the key empty:
  // FullCalendar's Number refiner would turn `undefined` into NaN.
  if (typeof props.firstDay === 'number') curated.firstDay = props.firstDay;

  // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
  // slot, route every cell render through it. The portal helper mounts the
  // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
  // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
  // handle is returned to FullCalendar so it cleans up the mounted tree when
  // the cell is removed. Consumers that don't fill the slot get FullCalendar's
  // default rendering (title text) — guarded by `$slots.event`.
  if (slots.event) {
    curated.eventContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.event(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  // The 9 remaining *Content portal-slots — wired identically to `event`, one
  // per FullCalendar per-cell content hook. Each guarded by its own slot so
  // unfilled slots keep FullCalendar's default rendering (or an
  // `options.*Content` passthrough). (10 portal-slots total counting `event`
  // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
  // hooks, and noEventsContent is the list-view "no events" hook — inert
  // unless the consumer engages @fullcalendar/list via :options.plugins.)
  //
  // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
  // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
  // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
  // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
  // unifies snippets and props into one `$props` namespace.
  if (slots.dayCell) {
    curated.dayCellContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayCell(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.dayHeader) {
    curated.dayHeaderContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayHeader(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.slotLabel) {
    curated.slotLabelContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLabel(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.weekNumber) {
    curated.weekNumberContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.weekNumber(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.nowIndicatorContent) {
    curated.nowIndicatorContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.nowIndicatorContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.moreLink) {
    curated.moreLinkContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.moreLink(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.allDayContent) {
    curated.allDayContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.allDayContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.slotLaneContent) {
    curated.slotLaneContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLaneContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  // noEventsContent — the list-view "no events to display" hook. Pre-declared
  // and wired like the other 9 *Content slots, but INERT unless the consumer
  // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
  // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
  // the bundled-only plugin set there is no list view, so this hook never fires
  // — by design, documented, zero bundle cost.
  if (slots.noEventsContent) {
    curated.noEventsContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.noEventsContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }

  // `events` is curated too: the prop owns its own event source (below), so an
  // `options.events` is ignored, exactly as before.
  curatedKeys = new Set([...Object.keys(curated), 'events']);
  const passthrough = Object.fromEntries(Object.entries(props.options ?? {}).filter(([k]: any) => !curatedKeys.has(k)));
  appliedOptions = new Map(Object.entries(passthrough));
  instance = new Calendar(__rozieRootRef.value!, {
    ...passthrough,
    ...curated
  });
  eventsSource = instance.addEventSource(props.events.map(normalizeEvent));
  instance.render();
  _cleanup_0 = () => {
    instance?.destroy();
    instance = null;
    eventsSource = null;
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => props.events, (v: any) => {
  if (!instance) return;
  eventsSource?.remove();
  eventsSource = instance.addEventSource((v ?? []).map(normalizeEvent));
}, { flush: 'post' });
watch(() => view.value, (v: any) => {
  if (!instance || !v) return;
  if (v === instance.view.type) return;
  suppressViewSync = true;
  instance.changeView(v);
}, { flush: 'post' });
watch(() => props.weekends, (v: any) => instance?.setOption('weekends', v), { flush: 'post' });
watch(() => props.editable, (v: any) => instance?.setOption('editable', v), { flush: 'post' });
watch(() => props.selectable, (v: any) => instance?.setOption('selectable', v), { flush: 'post' });
watch(() => props.height, (v: any) => instance?.setOption('height', normalizeHeight(v)), { flush: 'post' });
watch(() => props.locale, (v: any) => instance?.setOption('locale', v), { flush: 'post' });
watch(() => props.firstDay, (v: any) => {
  if (!instance || typeof v !== 'number') return;
  curatedKeys.add('firstDay');
  instance.setOption('firstDay', v);
}, { flush: 'post' });
watch(() => props.slotDuration, (v: any) => instance?.setOption('slotDuration', v), { flush: 'post' });
watch(() => props.nowIndicator, (v: any) => instance?.setOption('nowIndicator', v), { flush: 'post' });
watch(() => props.headerToolbar, (v: any) => instance?.setOption('headerToolbar', v), { flush: 'post' });
watch(() => props.options, (v: any) => {
  if (!instance || !v) return;
  for (const k in v) {
    if (curatedKeys.has(k)) continue;
    if (sameOptionValue(appliedOptions.get(k), v[k])) continue;
    appliedOptions.set(k, v[k]);
    instance.setOption(k, v[k]);
  }
}, { flush: 'post' });

defineExpose({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection } as FullCalendarHandle);
</script>

<style scoped>
.rozie-fullcalendar {
  width: 100%;
  font-size: 0.875rem;
}
</style>
svelte
<script module lang="ts">
// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import { onMount, untrack } from 'svelte';

interface Props {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
   */
  events?: any[];
  /**
   * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
   * @example
   * <FullCalendar bind:view events={events} />
   */
  view?: string;
  /**
   * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
   */
  weekends?: boolean;
  /**
   * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
   */
  editable?: boolean;
  /**
   * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
   */
  selectable?: boolean;
  /**
   * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
   */
  height?: string | number;
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  defaultColor?: string;
  /**
   * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
   */
  locale?: string;
  /**
   * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
   */
  firstDay?: (number) | null;
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  slotDuration?: string;
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  nowIndicator?: boolean;
  /**
   * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
   */
  headerToolbar?: any;
  /**
   * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
   */
  options?: any;
  event?: Snippet<[{ arg: EventContentArg }]>;
  dayCell?: Snippet<[{ arg: DayCellContentArg }]>;
  dayHeader?: Snippet<[{ arg: DayHeaderContentArg }]>;
  slotLabel?: Snippet<[{ arg: SlotLabelContentArg }]>;
  weekNumber?: Snippet<[{ arg: WeekNumberContentArg }]>;
  nowIndicatorContent?: Snippet<[{ arg: NowIndicatorContentArg }]>;
  moreLink?: Snippet<[{ arg: MoreLinkContentArg }]>;
  allDayContent?: Snippet<[{ arg: AllDayContentArg }]>;
  slotLaneContent?: Snippet<[{ arg: SlotLaneContentArg }]>;
  noEventsContent?: Snippet<[{ arg: FullCalendarNoEventsContentArg }]>;
  snippets?: Record<string, any>;
  oneventclick?: (payload: FullCalendarEventClick) => void;
  ondateclick?: (payload: FullCalendarDateClick) => void;
  oneventdrop?: (payload: FullCalendarEventDrop) => void;
  onselect?: (payload: FullCalendarSelection) => void;
  oneventresize?: (payload: FullCalendarEventResize) => void;
  ondatesset?: (payload: FullCalendarDatesSet) => void;
  oneventmouseenter?: (payload: FullCalendarEventPointer) => void;
  oneventmouseleave?: (payload: FullCalendarEventPointer) => void;
  onunselect?: (payload: FullCalendarUnselect) => void;
  onloading?: (payload: FullCalendarLoading) => void;
  oneventsset?: (payload: FullCalendarEventsSet) => void;
}

let __defaultEvents = (() => [])();
let __defaultHeaderToolbar = (() => ({
  left: 'prev,next today',
  center: 'title',
  right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))();
let __defaultOptions = (() => ({}))();

let {
  events = __defaultEvents,
  view = $bindable('dayGridMonth'),
  weekends = true,
  editable = true,
  selectable = true,
  height = 480,
  defaultColor = '#3b82f6',
  locale = 'en',
  firstDay = null,
  slotDuration = '00:30:00',
  nowIndicator = false,
  headerToolbar = __defaultHeaderToolbar,
  options = __defaultOptions,
  event: __eventProp,
  dayCell: __dayCellProp,
  dayHeader: __dayHeaderProp,
  slotLabel: __slotLabelProp,
  weekNumber: __weekNumberProp,
  nowIndicatorContent: __nowIndicatorContentProp,
  moreLink: __moreLinkProp,
  allDayContent: __allDayContentProp,
  slotLaneContent: __slotLaneContentProp,
  noEventsContent: __noEventsContentProp,
  snippets,
  oneventclick,
  ondateclick,
  oneventdrop,
  onselect,
  oneventresize,
  ondatesset,
  oneventmouseenter,
  oneventmouseleave,
  onunselect,
  onloading,
  oneventsset
}: Props = $props();

const event = $derived(__eventProp ?? snippets?.event);
const dayCell = $derived(__dayCellProp ?? snippets?.dayCell);
const dayHeader = $derived(__dayHeaderProp ?? snippets?.dayHeader);
const slotLabel = $derived(__slotLabelProp ?? snippets?.slotLabel);
const weekNumber = $derived(__weekNumberProp ?? snippets?.weekNumber);
const nowIndicatorContent = $derived(__nowIndicatorContentProp ?? snippets?.nowIndicatorContent);
const moreLink = $derived(__moreLinkProp ?? snippets?.moreLink);
const allDayContent = $derived(__allDayContentProp ?? snippets?.allDayContent);
const slotLaneContent = $derived(__slotLaneContentProp ?? snippets?.slotLaneContent);
const noEventsContent = $derived(__noEventsContentProp ?? snippets?.noEventsContent);

let __rozieRoot = $state<HTMLElement | undefined>(undefined);

const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
    if (!event) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-event', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: event, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
    if (!dayCell) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-dayCell', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: dayCell, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
    if (!dayHeader) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: dayHeader, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
    if (!slotLabel) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: slotLabel, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
    if (!weekNumber) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: weekNumber, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
    if (!nowIndicatorContent) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: nowIndicatorContent, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
    if (!moreLink) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-moreLink', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: moreLink, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
    if (!allDayContent) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: allDayContent, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
    if (!slotLaneContent) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: slotLaneContent, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
    if (!noEventsContent) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: noEventsContent, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
};
$effect(() => () => {
  for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
  portalInstances.clear();
});

import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
let instance: any = null;
let suppressViewSync = false;
// The event source the `events` prop owns. Reconciling replaces only this
// source, so events from `options.eventSources` or the `addEvent` verb survive.
let eventsSource: any = null;
// Keys the wrapper itself sets (curated props, wrapped callbacks, filled
// *Content slots). `:options` never overrides them, at mount or at runtime.
let curatedKeys = new Set();
// The `:options` values last handed to FullCalendar, per key, so the runtime
// reconcile only calls setOption for keys whose value actually changed.
let appliedOptions = new Map();
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
// Mirrors the `height` prop default.
const DEFAULT_HEIGHT = 480;
// A purely numeric string height ('600') means pixels. Needed because a static
// Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
// the height prop) would otherwise regress `height="600"`, which the old Number
// converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
// passes through. An empty string, null/undefined, or a non-positive number
// falls back to the default so every target sizes the calendar the same way.
const normalizeHeight = (h: any) => {
  let v = h;
  if (typeof v === 'string') {
    const t = v.trim();
    if (t === '') return DEFAULT_HEIGHT;
    if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
  }
  if (typeof v !== 'number' || !(v > 0)) return DEFAULT_HEIGHT;
  return v;
};
const normalizeEvent = (e: any) => {
  // Object spread — common reconcile shape: pass user props through, normalize
  // the title to a string WITHOUT inventing text (internal ids must never
  // render), and honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || '',
    color: e.color || defaultColor
  };
};
// The normalized event ref every payload carries.
const eventRef = (e: any) => ({
  id: e.id,
  title: e.title,
  start: e.start,
  end: e.end
});
// Structural equality for plain option values (arrays and plain objects by
// content, everything else — functions included — by identity). Lets an inline
// `:options` literal re-created on every parent render stay a no-op.
const sameOptionValue = (a: any, b: any) => {
  if (a === b) return true;
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) if (!sameOptionValue(a[i], b[i])) return false;
    return true;
  }
  const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
  if (isPlain(a) && isPlain(b)) {
    const ka = Object.keys(a);
    if (ka.length !== Object.keys(b).length) return false;
    for (const k of ka as any) if (!(k in b) || !sameOptionValue(a[k], b[k])) return false;
    return true;
  }
  return false;
};
export function getApi(): Calendar | null;
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and again after
// unmount (destroyed and cleared) — callers handle the null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
export function getApi() {
  return instance;
}
export function changeView(viewType: string, dateOrRange?: DateRangeInput | DateInput): void;
export function changeView(...a: any[]) {
  return instance?.changeView(...a);
}
export function addEvent(event: EventInput, source?: EventSourceApi | string | boolean): EventApi | null | undefined;
// Normalized like the `events` prop (title, defaultColor).
export function addEvent(event: any, source: any) {
  return instance?.addEvent(normalizeEvent(event), source);
}
export function removeEvent(id: string): void;
export function removeEvent(id: any) {
  instance?.getEventById(id)?.remove();
}
export function today(): void;
export function today() {
  instance?.today();
}
export function prev(): void;
export function prev() {
  instance?.prev();
}
export function next(): void;
export function next() {
  instance?.next();
}
export function gotoDate(date: DateInput): void;
export function gotoDate(...a: any[]) {
  instance?.gotoDate(...a);
}
export function getDate(): Date | null;
export function getDate() {
  return instance ? instance.getDate() : null;
}
export function getEvents(): EventApi[];
export function getEvents() {
  return instance ? instance.getEvents() : [];
}
export function scrollToTime(time: DurationInput): void;
export function scrollToTime(...a: any[]) {
  instance?.scrollToTime(...a);
}
export function updateSize(): void;
export function updateSize() {
  instance?.updateSize();
}
export function prevYear(): void;
export function prevYear() {
  instance?.prevYear();
}
export function nextYear(): void;
export function nextYear() {
  instance?.nextYear();
}
export function selectRange(dateOrSpan: DateInput | DateSpanInput, end?: DateInput): void;
export function selectRange(...a: any[]) {
  instance?.select(...a);
}
export function clearSelection(): void;
export function clearSelection() {
  instance?.unselect();
}

onMount(() => {
  // The curated config — every key the wrapper owns. The `:options`
  // passthrough below fills only the gaps (curated keys always win).
  //
  // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
  // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
  // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
  // interaction) with any consumer-added plugins. This makes the wrapper
  // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
  // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
  // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
  // consumer re-passing a default is harmless.
  // A null-let (typeNeutralize → `any` in every leaf): keys are added below
  // (`firstDay`, the filled *Content slots), which a strict object-literal type
  // would reject (TS2339).
  let curated: any = null;
  curated = {
    plugins: [...PLUGINS, ...(options?.plugins ?? [])],
    initialView: view,
    weekends: weekends,
    editable: editable,
    selectable: selectable,
    height: normalizeHeight(height),
    locale: locale,
    slotDuration: slotDuration,
    nowIndicator: nowIndicator,
    // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
    // toolbar; the built-in default lives in the `headerToolbar` prop default.
    headerToolbar: headerToolbar,
    eventClick: (info: any) => {
      oneventclick?.({
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    dateClick: (info: any) => {
      ondateclick?.({
        date: info.date,
        dateStr: info.dateStr,
        allDay: info.allDay,
        dayEl: info.dayEl,
        jsEvent: info.jsEvent
      });
    },
    eventDrop: (info: any) => {
      oneventdrop?.({
        event: eventRef(info.event),
        oldEvent: eventRef(info.oldEvent),
        delta: info.delta,
        revert: info.revert
      });
    },
    select: (info: any) => {
      onselect?.({
        start: info.start,
        end: info.end,
        startStr: info.startStr,
        endStr: info.endStr,
        allDay: info.allDay
      });
    },
    eventResize: (info: any) => {
      oneventresize?.({
        event: eventRef(info.event),
        oldEvent: eventRef(info.oldEvent),
        startDelta: info.startDelta,
        endDelta: info.endDelta,
        revert: info.revert
      });
    },
    datesSet: (info: any) => {
      ondatesset?.({
        start: info.start,
        end: info.end,
        view: info.view.type
      });
    },
    eventMouseEnter: (info: any) => {
      oneventmouseenter?.({
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    eventMouseLeave: (info: any) => {
      oneventmouseleave?.({
        event: eventRef(info.event),
        jsEvent: info.jsEvent,
        el: info.el
      });
    },
    unselect: (info: any) => {
      onunselect?.({
        jsEvent: info.jsEvent
      });
    },
    loading: (isLoading: any) => {
      // FullCalendar's `loading` callback receives a bare boolean (not an info
      // object) — normalize to the structured `{ isLoading }` payload shape.
      onloading?.({
        isLoading
      });
    },
    eventsSet: (events: any) => {
      // `eventsSet` receives the array of current EventApi objects — map each to
      // the normalized floor shape for persistence/sync consumers.
      oneventsset?.({
        events: events.map(eventRef)
      });
    },
    viewDidMount: (info: any) => {
      // viewDidMount fires both on initial mount AND on changeView calls.
      // Same round-trip guard pattern as Flatpickr / LeafletMap.
      if (suppressViewSync) {
        suppressViewSync = false;
        return;
      }
      if (info.view.type !== view) view = info.view.type;
    },
    eventDidMount: (info: any) => {
      // Every event is focusable (the wrapper always handles eventClick), so an
      // untitled one needs an accessible name. A consumer's own
      // `options.eventDidMount` still runs, read live so updates apply.
      if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
        info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
      }
      const own = options?.eventDidMount;
      if (typeof own === 'function') own(info);
    }
  };
  // Unset (null) keeps the locale's own first day. Never pass the key empty:
  // FullCalendar's Number refiner would turn `undefined` into NaN.
  if (typeof firstDay === 'number') curated.firstDay = firstDay;

  // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
  // slot, route every cell render through it. The portal helper mounts the
  // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
  // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
  // handle is returned to FullCalendar so it cleans up the mounted tree when
  // the cell is removed. Consumers that don't fill the slot get FullCalendar's
  // default rendering (title text) — guarded by `$slots.event`.
  if (event) {
    curated.eventContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.event(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  // The 9 remaining *Content portal-slots — wired identically to `event`, one
  // per FullCalendar per-cell content hook. Each guarded by its own slot so
  // unfilled slots keep FullCalendar's default rendering (or an
  // `options.*Content` passthrough). (10 portal-slots total counting `event`
  // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
  // hooks, and noEventsContent is the list-view "no events" hook — inert
  // unless the consumer engages @fullcalendar/list via :options.plugins.)
  //
  // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
  // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
  // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
  // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
  // unifies snippets and props into one `$props` namespace.
  if (dayCell) {
    curated.dayCellContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayCell(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (dayHeader) {
    curated.dayHeaderContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayHeader(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slotLabel) {
    curated.slotLabelContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLabel(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (weekNumber) {
    curated.weekNumberContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.weekNumber(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (nowIndicatorContent) {
    curated.nowIndicatorContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.nowIndicatorContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (moreLink) {
    curated.moreLinkContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.moreLink(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (allDayContent) {
    curated.allDayContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.allDayContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slotLaneContent) {
    curated.slotLaneContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLaneContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  // noEventsContent — the list-view "no events to display" hook. Pre-declared
  // and wired like the other 9 *Content slots, but INERT unless the consumer
  // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
  // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
  // the bundled-only plugin set there is no list view, so this hook never fires
  // — by design, documented, zero bundle cost.
  if (noEventsContent) {
    curated.noEventsContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.noEventsContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }

  // `events` is curated too: the prop owns its own event source (below), so an
  // `options.events` is ignored, exactly as before.
  curatedKeys = new Set([...Object.keys(curated), 'events']);
  const passthrough = Object.fromEntries(Object.entries(options ?? {}).filter(([k]: any) => !curatedKeys.has(k)));
  appliedOptions = new Map(Object.entries(passthrough));
  instance = new Calendar(__rozieRoot!, {
    ...passthrough,
    ...curated
  });
  eventsSource = instance.addEventSource(events.map(normalizeEvent));
  instance.render();
  return () => {
    instance?.destroy();
    instance = null;
    eventsSource = null;
  };
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => events)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
  if (!instance) return;
  eventsSource?.remove();
  eventsSource = instance.addEventSource((v ?? []).map(normalizeEvent));
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => view)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => {
  if (!instance || !v) return;
  if (v === instance.view.type) return;
  suppressViewSync = true;
  instance.changeView(v);
})(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => weekends)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => instance?.setOption('weekends', v))(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => editable)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => instance?.setOption('editable', v))(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => selectable)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => instance?.setOption('selectable', v))(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => height)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => instance?.setOption('height', normalizeHeight(v)))(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => locale)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => instance?.setOption('locale', v))(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => firstDay)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => {
  if (!instance || typeof v !== 'number') return;
  curatedKeys.add('firstDay');
  instance.setOption('firstDay', v);
})(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { const __watchVal = (() => slotDuration)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } ((v: any) => instance?.setOption('slotDuration', v))(__watchVal); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { const __watchVal = (() => nowIndicator)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } ((v: any) => instance?.setOption('nowIndicator', v))(__watchVal); }); });
let __rozieWatchInitial_10 = true;
$effect(() => { const __watchVal = (() => headerToolbar)(); untrack(() => { if (__rozieWatchInitial_10) { __rozieWatchInitial_10 = false; return; } ((v: any) => instance?.setOption('headerToolbar', v))(__watchVal); }); });
let __rozieWatchInitial_11 = true;
$effect(() => { const __watchVal = (() => options)(); untrack(() => { if (__rozieWatchInitial_11) { __rozieWatchInitial_11 = false; return; } ((v: any) => {
  if (!instance || !v) return;
  for (const k in v) {
    if (curatedKeys.has(k)) continue;
    if (sameOptionValue(appliedOptions.get(k), v[k])) continue;
    appliedOptions.set(k, v[k]);
    instance.setOption(k, v[k]);
  }
})(__watchVal); }); });
</script>

<div class="rozie-fullcalendar" bind:this={__rozieRoot} data-rozie-s-5589629a></div>

<style>
:global {
  .rozie-fullcalendar[data-rozie-s-5589629a] {
    width: 100%;
    font-size: 0.875rem;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, computed, contentChild, contentChildren, effect, inject, input, model, output, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RozieSlot } from '@rozie/runtime-angular';

import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';

interface EventCtx {
  $implicit: { arg: EventContentArg };
  arg: EventContentArg;
}

interface DayCellCtx {
  $implicit: { arg: DayCellContentArg };
  arg: DayCellContentArg;
}

interface DayHeaderCtx {
  $implicit: { arg: DayHeaderContentArg };
  arg: DayHeaderContentArg;
}

interface SlotLabelCtx {
  $implicit: { arg: SlotLabelContentArg };
  arg: SlotLabelContentArg;
}

interface WeekNumberCtx {
  $implicit: { arg: WeekNumberContentArg };
  arg: WeekNumberContentArg;
}

interface NowIndicatorContentCtx {
  $implicit: { arg: NowIndicatorContentArg };
  arg: NowIndicatorContentArg;
}

interface MoreLinkCtx {
  $implicit: { arg: MoreLinkContentArg };
  arg: MoreLinkContentArg;
}

interface AllDayContentCtx {
  $implicit: { arg: AllDayContentArg };
  arg: AllDayContentArg;
}

interface SlotLaneContentCtx {
  $implicit: { arg: SlotLaneContentArg };
  arg: SlotLaneContentArg;
}

interface NoEventsContentCtx {
  $implicit: { arg: FullCalendarNoEventsContentArg };
  arg: FullCalendarNoEventsContentArg;
}

@Component({
  selector: 'rozie-full-calendar',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `

    <div class="rozie-fullcalendar" #__rozieRoot></div>











    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    :host(rozie-full-calendar) { display: contents; }
    .rozie-fullcalendar {
      width: 100%;
      font-size: 0.875rem;
    }
  `],
})
export class FullCalendar {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
   */
  events = input<any[]>((() => [])());
  /**
   * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
   * @example
   * <rozie-full-calendar [(view)]="view" [events]="events" />
   */
  view = model<string>('dayGridMonth');
  /**
   * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
   */
  weekends = input<boolean>(true);
  /**
   * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
   */
  editable = input<boolean>(true);
  /**
   * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
   */
  selectable = input<boolean>(true);
  /**
   * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
   */
  height = input<string | number>(480);
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  defaultColor = input<string>('#3b82f6');
  /**
   * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
   */
  locale = input<string>('en');
  /**
   * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
   */
  firstDay = input<(number) | null>(null);
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  slotDuration = input<string>('00:30:00');
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  nowIndicator = input<boolean>(false);
  /**
   * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
   */
  headerToolbar = input<Record<string, any>>((() => ({
    left: 'prev,next today',
    center: 'title',
    right: 'dayGridMonth,timeGridWeek,timeGridDay'
  }))());
  /**
   * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
   */
  options = input<Record<string, any>>((() => ({}))());
  __rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
  eventClick = output<FullCalendarEventClick>();
  dateClick = output<FullCalendarDateClick>();
  eventDrop = output<FullCalendarEventDrop>();
  select = output<FullCalendarSelection>();
  eventResize = output<FullCalendarEventResize>();
  datesSet = output<FullCalendarDatesSet>();
  eventMouseEnter = output<FullCalendarEventPointer>();
  eventMouseLeave = output<FullCalendarEventPointer>();
  unselect = output<FullCalendarUnselect>();
  loading = output<FullCalendarLoading>();
  eventsSet = output<FullCalendarEventsSet>();
  @ContentChild('event', { read: TemplateRef }) eventTpl?: TemplateRef<EventCtx>;
  @ContentChild('dayCell', { read: TemplateRef }) dayCellTpl?: TemplateRef<DayCellCtx>;
  @ContentChild('dayHeader', { read: TemplateRef }) dayHeaderTpl?: TemplateRef<DayHeaderCtx>;
  @ContentChild('slotLabel', { read: TemplateRef }) slotLabelTpl?: TemplateRef<SlotLabelCtx>;
  @ContentChild('weekNumber', { read: TemplateRef }) weekNumberTpl?: TemplateRef<WeekNumberCtx>;
  @ContentChild('nowIndicatorContent', { read: TemplateRef }) nowIndicatorContentTpl?: TemplateRef<NowIndicatorContentCtx>;
  @ContentChild('moreLink', { read: TemplateRef }) moreLinkTpl?: TemplateRef<MoreLinkCtx>;
  @ContentChild('allDayContent', { read: TemplateRef }) allDayContentTpl?: TemplateRef<AllDayContentCtx>;
  @ContentChild('slotLaneContent', { read: TemplateRef }) slotLaneContentTpl?: TemplateRef<SlotLaneContentCtx>;
  @ContentChild('noEventsContent', { read: TemplateRef }) noEventsContentTpl?: TemplateRef<NoEventsContentCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _eventTpl = contentChild('event', { read: TemplateRef });
  private _dayCellTpl = contentChild('dayCell', { read: TemplateRef });
  private _dayHeaderTpl = contentChild('dayHeader', { read: TemplateRef });
  private _slotLabelTpl = contentChild('slotLabel', { read: TemplateRef });
  private _weekNumberTpl = contentChild('weekNumber', { read: TemplateRef });
  private _nowIndicatorContentTpl = contentChild('nowIndicatorContent', { read: TemplateRef });
  private _moreLinkTpl = contentChild('moreLink', { read: TemplateRef });
  private _allDayContentTpl = contentChild('allDayContent', { read: TemplateRef });
  private _slotLaneContentTpl = contentChild('slotLaneContent', { read: TemplateRef });
  private _noEventsContentTpl = contentChild('noEventsContent', { read: TemplateRef });
  private portals = {
    event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
      const tpl = this._eventTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-event', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
      const tpl = this._dayCellTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-dayCell', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
      const tpl = this._dayHeaderTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
      const tpl = this._slotLabelTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
      const tpl = this._weekNumberTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
      const tpl = this._nowIndicatorContentTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
      const tpl = this._moreLinkTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-moreLink', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
      const tpl = this._allDayContentTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
      const tpl = this._slotLaneContentTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
    noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
      const tpl = this._noEventsContentTpl();
      const vcr = this._portalAnchor();
      if (!tpl || !vcr) return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
      const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
      view.detectChanges();
      for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
      this._portalViews.add(view as EmbeddedViewRef<unknown>);
      return () => {
        view.destroy();
        this._portalViews.delete(view as EmbeddedViewRef<unknown>);
      };
    },
  };
  private __rozieDestroyRef = inject(DestroyRef);
  private __rozieWatchInitial_0 = true;
  private __rozieWatchInitial_1 = true;
  private __rozieWatchInitial_2 = true;
  private __rozieWatchInitial_3 = true;
  private __rozieWatchInitial_4 = true;
  private __rozieWatchInitial_5 = true;
  private __rozieWatchInitial_6 = true;
  private __rozieWatchInitial_7 = true;
  private __rozieWatchInitial_8 = true;
  private __rozieWatchInitial_9 = true;
  private __rozieWatchInitial_10 = true;
  private __rozieWatchInitial_11 = true;

  constructor() {
    effect(() => { const __watchVal = (() => this.events())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
      if (!this.instance) return;
      this.eventsSource?.remove();
      this.eventsSource = this.instance.addEventSource((v ?? []).map(this.normalizeEvent));
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.view())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
      if (!this.instance || !v) return;
      if (v === this.instance.view.type) return;
      this.suppressViewSync = true;
      this.instance.changeView(v);
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.weekends())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => this.instance?.setOption('weekends', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.editable())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => this.instance?.setOption('editable', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.selectable())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => this.instance?.setOption('selectable', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.height())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => this.instance?.setOption('height', this.normalizeHeight(v)))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.locale())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => this.instance?.setOption('locale', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.firstDay())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => {
      if (!this.instance || typeof v !== 'number') return;
      this.curatedKeys.add('firstDay');
      this.instance.setOption('firstDay', v);
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.slotDuration())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } ((v: any) => this.instance?.setOption('slotDuration', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.nowIndicator())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } ((v: any) => this.instance?.setOption('nowIndicator', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.headerToolbar())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } ((v: any) => this.instance?.setOption('headerToolbar', v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.options())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } ((v: any) => {
      if (!this.instance || !v) return;
      for (const k in v) {
        if (this.curatedKeys.has(k)) continue;
        if (this.sameOptionValue(this.appliedOptions.get(k), v[k])) continue;
        this.appliedOptions.set(k, v[k]);
        this.instance.setOption(k, v[k]);
      }
    })(__watchVal); }); });
  }

  ngAfterViewInit() {
    const __options = this.options();
    const __firstDay = this.firstDay();
    // The curated config — every key the wrapper owns. The `:options`
    // passthrough below fills only the gaps (curated keys always win).
    //
    // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
    // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
    // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
    // interaction) with any consumer-added plugins. This makes the wrapper
    // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
    // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
    // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
    // consumer re-passing a default is harmless.
    // A null-let (typeNeutralize → `any` in every leaf): keys are added below
    // (`firstDay`, the filled *Content slots), which a strict object-literal type
    // would reject (TS2339).
    let curated: any = null;
    curated = {
      plugins: [...this.PLUGINS, ...(__options?.plugins ?? [])],
      initialView: this.view(),
      weekends: this.weekends(),
      editable: this.editable(),
      selectable: this.selectable(),
      height: this.normalizeHeight(this.height()),
      locale: this.locale(),
      slotDuration: this.slotDuration(),
      nowIndicator: this.nowIndicator(),
      // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
      // toolbar; the built-in default lives in the `headerToolbar` prop default.
      headerToolbar: this.headerToolbar(),
      eventClick: (info: any) => {
        this.eventClick.emit({
          event: this.eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      dateClick: (info: any) => {
        this.dateClick.emit({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay,
          dayEl: info.dayEl,
          jsEvent: info.jsEvent
        });
      },
      eventDrop: (info: any) => {
        this.eventDrop.emit({
          event: this.eventRef(info.event),
          oldEvent: this.eventRef(info.oldEvent),
          delta: info.delta,
          revert: info.revert
        });
      },
      select: (info: any) => {
        this.select.emit({
          start: info.start,
          end: info.end,
          startStr: info.startStr,
          endStr: info.endStr,
          allDay: info.allDay
        });
      },
      eventResize: (info: any) => {
        this.eventResize.emit({
          event: this.eventRef(info.event),
          oldEvent: this.eventRef(info.oldEvent),
          startDelta: info.startDelta,
          endDelta: info.endDelta,
          revert: info.revert
        });
      },
      datesSet: (info: any) => {
        this.datesSet.emit({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        this.eventMouseEnter.emit({
          event: this.eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      eventMouseLeave: (info: any) => {
        this.eventMouseLeave.emit({
          event: this.eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      unselect: (info: any) => {
        this.unselect.emit({
          jsEvent: info.jsEvent
        });
      },
      loading: (isLoading: any) => {
        // FullCalendar's `loading` callback receives a bare boolean (not an info
        // object) — normalize to the structured `{ isLoading }` payload shape.
        this.loading.emit({
          isLoading
        });
      },
      eventsSet: (events: any) => {
        // `eventsSet` receives the array of current EventApi objects — map each to
        // the normalized floor shape for persistence/sync consumers.
        this.eventsSet.emit({
          events: events.map(this.eventRef)
        });
      },
      viewDidMount: (info: any) => {
        // viewDidMount fires both on initial mount AND on changeView calls.
        // Same round-trip guard pattern as Flatpickr / LeafletMap.
        if (this.suppressViewSync) {
          this.suppressViewSync = false;
          return;
        }
        if (info.view.type !== this.view()) this.view.set(info.view.type);
      },
      eventDidMount: (info: any) => {
        // Every event is focusable (the wrapper always handles eventClick), so an
        // untitled one needs an accessible name. A consumer's own
        // `options.eventDidMount` still runs, read live so updates apply.
        if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
          info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
        }
        const own = __options?.eventDidMount;
        if (typeof own === 'function') own(info);
      }
    };
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    if (typeof __firstDay === 'number') curated.firstDay = __firstDay;

    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    if ((this.eventTpl ?? this.__rozieFillMap()['event'] ?? this.templates()?.['event'])) {
      curated.eventContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.event(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    if ((this.dayCellTpl ?? this.__rozieFillMap()['dayCell'] ?? this.templates()?.['dayCell'])) {
      curated.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.dayHeaderTpl ?? this.__rozieFillMap()['dayHeader'] ?? this.templates()?.['dayHeader'])) {
      curated.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.slotLabelTpl ?? this.__rozieFillMap()['slotLabel'] ?? this.templates()?.['slotLabel'])) {
      curated.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.weekNumberTpl ?? this.__rozieFillMap()['weekNumber'] ?? this.templates()?.['weekNumber'])) {
      curated.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.nowIndicatorContentTpl ?? this.__rozieFillMap()['nowIndicatorContent'] ?? this.templates()?.['nowIndicatorContent'])) {
      curated.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.moreLinkTpl ?? this.__rozieFillMap()['moreLink'] ?? this.templates()?.['moreLink'])) {
      curated.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.allDayContentTpl ?? this.__rozieFillMap()['allDayContent'] ?? this.templates()?.['allDayContent'])) {
      curated.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.slotLaneContentTpl ?? this.__rozieFillMap()['slotLaneContent'] ?? this.templates()?.['slotLaneContent'])) {
      curated.slotLaneContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.slotLaneContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    if ((this.noEventsContentTpl ?? this.__rozieFillMap()['noEventsContent'] ?? this.templates()?.['noEventsContent'])) {
      curated.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }

    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    this.curatedKeys = new Set([...Object.keys(curated), 'events']);
    const passthrough = Object.fromEntries(Object.entries(__options ?? {}).filter(([k]: any) => !this.curatedKeys.has(k)));
    this.appliedOptions = new Map(Object.entries(passthrough));
    this.instance = new Calendar(this.__rozieRoot()!.nativeElement, {
      ...passthrough,
      ...curated
    });
    this.eventsSource = this.instance.addEventSource(this.events().map(this.normalizeEvent));
    this.instance.render();
    this.__rozieDestroyRef.onDestroy(() => {
      this.instance?.destroy();
      this.instance = null;
      this.eventsSource = null;
    });
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  instance: any = null;
  suppressViewSync = false;
  // The event source the `events` prop owns. Reconciling replaces only this
  // source, so events from `options.eventSources` or the `addEvent` verb survive.
  eventsSource: any = null;
  // Keys the wrapper itself sets (curated props, wrapped callbacks, filled
  // *Content slots). `:options` never overrides them, at mount or at runtime.
  curatedKeys = new Set();
  // The `:options` values last handed to FullCalendar, per key, so the runtime
  // reconcile only calls setOption for keys whose value actually changed.
  appliedOptions = new Map();
  PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
  // Mirrors the `height` prop default.
  DEFAULT_HEIGHT = 480;
  // A purely numeric string height ('600') means pixels. Needed because a static
  // Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
  // the height prop) would otherwise regress `height="600"`, which the old Number
  // converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
  // passes through. An empty string, null/undefined, or a non-positive number
  // falls back to the default so every target sizes the calendar the same way.
  normalizeHeight = (h: any) => {
    let v = h;
    if (typeof v === 'string') {
      const t = v.trim();
      if (t === '') return this.DEFAULT_HEIGHT;
      if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
    }
    if (typeof v !== 'number' || !(v > 0)) return this.DEFAULT_HEIGHT;
    return v;
  };
  normalizeEvent = (e: any) => {
    // Object spread — common reconcile shape: pass user props through, normalize
    // the title to a string WITHOUT inventing text (internal ids must never
    // render), and honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || '',
      color: e.color || this.defaultColor()
    };
  };
  // The normalized event ref every payload carries.
  eventRef = (e: any) => ({
    id: e.id,
    title: e.title,
    start: e.start,
    end: e.end
  });
  // Structural equality for plain option values (arrays and plain objects by
  // content, everything else — functions included — by identity). Lets an inline
  // `:options` literal re-created on every parent render stay a no-op.
  sameOptionValue = (a: any, b: any) => {
    if (a === b) return true;
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      for (let i = 0; i < a.length; i++) if (!this.sameOptionValue(a[i], b[i])) return false;
      return true;
    }
    const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
    if (isPlain(a) && isPlain(b)) {
      const ka = Object.keys(a);
      if (ka.length !== Object.keys(b).length) return false;
      for (const k of ka as any) if (!(k in b) || !this.sameOptionValue(a[k], b[k])) return false;
      return true;
    }
    return false;
  };
  // Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets
  // (Vue defineExpose / React useImperativeHandle / Svelte instance export /
  // Angular+Lit public method / Solid callback ref). Each delegates to the
  // underlying Calendar instance, which is null before $onMount and again after
  // unmount (destroyed and cleared) — callers handle the null.
  //
  // Collision discipline (the load-bearing flatpickr lesson): no exposed name may
  // collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
  // datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
  // a declared prop. This is why the selection verbs are NAMED `selectRange`
  // (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
  // `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
  // is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
  //
  // Read-back gap closed: getDate (current anchor — the `view` model only carries
  // the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
  // event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
  // scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
  getApi: () => Calendar | null = () => {
    return this.instance;
  };
  changeView: (viewType: string, dateOrRange?: DateRangeInput | DateInput) => void = (...a: any[]) => {
    return this.instance?.changeView(...a);
  };
  // Normalized like the `events` prop (title, defaultColor).
  addEvent: (event: EventInput, source?: EventSourceApi | string | boolean) => EventApi | null | undefined = (event: any, source: any) => {
    return this.instance?.addEvent(this.normalizeEvent(event), source);
  };
  removeEvent: (id: string) => void = (id: any) => {
    this.instance?.getEventById(id)?.remove();
  };
  today: () => void = () => {
    this.instance?.today();
  };
  prev: () => void = () => {
    this.instance?.prev();
  };
  next: () => void = () => {
    this.instance?.next();
  };
  gotoDate: (date: DateInput) => void = (...a: any[]) => {
    this.instance?.gotoDate(...a);
  };
  getDate: () => Date | null = () => {
    return this.instance ? this.instance.getDate() : null;
  };
  getEvents: () => EventApi[] = () => {
    return this.instance ? this.instance.getEvents() : [];
  };
  scrollToTime: (time: DurationInput) => void = (...a: any[]) => {
    this.instance?.scrollToTime(...a);
  };
  updateSize: () => void = () => {
    this.instance?.updateSize();
  };
  prevYear: () => void = () => {
    this.instance?.prevYear();
  };
  nextYear: () => void = () => {
    this.instance?.nextYear();
  };
  selectRange: (dateOrSpan: DateInput | DateSpanInput, end?: DateInput) => void = (...a: any[]) => {
    this.instance?.select(...a);
  };
  clearSelection: () => void = () => {
    this.instance?.unselect();
  };

  static ngTemplateContextGuard(
    _dir: FullCalendar,
    _ctx: unknown,
  ): _ctx is EventCtx | DayCellCtx | DayHeaderCtx | SlotLabelCtx | WeekNumberCtx | NowIndicatorContentCtx | MoreLinkCtx | AllDayContentCtx | SlotLaneContentCtx | NoEventsContentCtx {
    return true;
  }
}

export default FullCalendar;
tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';

__rozieInjectStyle('FullCalendar-5589629a', `.rozie-fullcalendar[data-rozie-s-5589629a] {
  width: 100%;
  font-size: 0.875rem;
}`);

interface EventSlotCtx { arg: EventContentArg; }

interface DayCellSlotCtx { arg: DayCellContentArg; }

interface DayHeaderSlotCtx { arg: DayHeaderContentArg; }

interface SlotLabelSlotCtx { arg: SlotLabelContentArg; }

interface WeekNumberSlotCtx { arg: WeekNumberContentArg; }

interface NowIndicatorContentSlotCtx { arg: NowIndicatorContentArg; }

interface MoreLinkSlotCtx { arg: MoreLinkContentArg; }

interface AllDayContentSlotCtx { arg: AllDayContentArg; }

interface SlotLaneContentSlotCtx { arg: SlotLaneContentArg; }

interface NoEventsContentSlotCtx { arg: FullCalendarNoEventsContentArg; }

interface FullCalendarProps {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
   */
  events?: any[];
  /**
   * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
   * @example
   * <FullCalendar view={view()} onViewChange={setView} events={events} />
   */
  view?: string;
  defaultView?: string;
  onViewChange?: (view: string) => void;
  /**
   * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
   */
  weekends?: boolean;
  /**
   * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
   */
  editable?: boolean;
  /**
   * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
   */
  selectable?: boolean;
  /**
   * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
   */
  height?: string | number;
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  defaultColor?: string;
  /**
   * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
   */
  locale?: string;
  /**
   * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
   */
  firstDay?: (number) | null;
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  slotDuration?: string;
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  nowIndicator?: boolean;
  /**
   * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
   */
  headerToolbar?: Record<string, any>;
  /**
   * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
   */
  options?: Record<string, any>;
  onEventClick?: (payload: FullCalendarEventClick) => void;
  onDateClick?: (payload: FullCalendarDateClick) => void;
  onEventDrop?: (payload: FullCalendarEventDrop) => void;
  onSelect?: (payload: FullCalendarSelection) => void;
  onEventResize?: (payload: FullCalendarEventResize) => void;
  onDatesSet?: (payload: FullCalendarDatesSet) => void;
  onEventMouseEnter?: (payload: FullCalendarEventPointer) => void;
  onEventMouseLeave?: (payload: FullCalendarEventPointer) => void;
  onUnselect?: (payload: FullCalendarUnselect) => void;
  onLoading?: (payload: FullCalendarLoading) => void;
  onEventsSet?: (payload: FullCalendarEventsSet) => void;
  eventSlot?: (ctx: EventSlotCtx) => JSX.Element;
  dayCellSlot?: (ctx: DayCellSlotCtx) => JSX.Element;
  dayHeaderSlot?: (ctx: DayHeaderSlotCtx) => JSX.Element;
  slotLabelSlot?: (ctx: SlotLabelSlotCtx) => JSX.Element;
  weekNumberSlot?: (ctx: WeekNumberSlotCtx) => JSX.Element;
  nowIndicatorContentSlot?: (ctx: NowIndicatorContentSlotCtx) => JSX.Element;
  moreLinkSlot?: (ctx: MoreLinkSlotCtx) => JSX.Element;
  allDayContentSlot?: (ctx: AllDayContentSlotCtx) => JSX.Element;
  slotLaneContentSlot?: (ctx: SlotLaneContentSlotCtx) => JSX.Element;
  noEventsContentSlot?: (ctx: NoEventsContentSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: FullCalendarHandle) => void;
}

export interface FullCalendarHandle {
  getApi: () => Calendar | null;
  changeView: (viewType: string, dateOrRange?: DateRangeInput | DateInput) => void;
  addEvent: (event: EventInput, source?: EventSourceApi | string | boolean) => EventApi | null | undefined;
  removeEvent: (id: string) => void;
  today: () => void;
  prev: () => void;
  next: () => void;
  gotoDate: (date: DateInput) => void;
  getDate: () => Date | null;
  getEvents: () => EventApi[];
  scrollToTime: (time: DurationInput) => void;
  updateSize: () => void;
  prevYear: () => void;
  nextYear: () => void;
  selectRange: (dateOrSpan: DateInput | DateSpanInput, end?: DateInput) => void;
  clearSelection: () => void;
}

export default function FullCalendar(_props: FullCalendarProps): JSX.Element {
  const _merged = mergeProps({ events: (() => [])() as any[], weekends: true, editable: true, selectable: true, height: 480, defaultColor: '#3b82f6', locale: 'en', firstDay: null, slotDuration: '00:30:00', nowIndicator: false, headerToolbar: (() => ({
  left: 'prev,next today',
  center: 'title',
  right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))() as Record<string, any>, options: (() => ({}))() as Record<string, any> }, _props);
  const [local, attrs] = splitProps(_merged, ['events', 'view', 'weekends', 'editable', 'selectable', 'height', 'defaultColor', 'locale', 'firstDay', 'slotDuration', 'nowIndicator', 'headerToolbar', 'options', 'ref', 'onEventClick', 'onDateClick', 'onEventDrop', 'onSelect', 'onEventResize', 'onDatesSet', 'onEventMouseEnter', 'onEventMouseLeave', 'onUnselect', 'onLoading', 'onEventsSet']);
  onMount(() => { local.ref?.({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection }); });

  const [view, setView] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'view', 'dayGridMonth');
  const portalDisposers = new Set<() => void>();
  const portals = {
    event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
      const slot = _props.eventSlot ?? _props.slots?.['event'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-event', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
      const slot = _props.dayCellSlot ?? _props.slots?.['dayCell'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-dayCell', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
      const slot = _props.dayHeaderSlot ?? _props.slots?.['dayHeader'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
      const slot = _props.slotLabelSlot ?? _props.slots?.['slotLabel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
      const slot = _props.weekNumberSlot ?? _props.slots?.['weekNumber'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
      const slot = _props.nowIndicatorContentSlot ?? _props.slots?.['nowIndicatorContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
      const slot = _props.moreLinkSlot ?? _props.slots?.['moreLink'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-moreLink', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
      const slot = _props.allDayContentSlot ?? _props.slots?.['allDayContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
      const slot = _props.slotLaneContentSlot ?? _props.slots?.['slotLaneContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
      const slot = _props.noEventsContentSlot ?? _props.slots?.['noEventsContent'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
  };
  onCleanup(() => {
    for (const dispose of portalDisposers) dispose();
    portalDisposers.clear();
  });
  onMount(() => {
    const _cleanup = (() => {
    // The curated config — every key the wrapper owns. The `:options`
    // passthrough below fills only the gaps (curated keys always win).
    //
    // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
    // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
    // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
    // interaction) with any consumer-added plugins. This makes the wrapper
    // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
    // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
    // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
    // consumer re-passing a default is harmless.
    // A null-let (typeNeutralize → `any` in every leaf): keys are added below
    // (`firstDay`, the filled *Content slots), which a strict object-literal type
    // would reject (TS2339).
    let curated: any = null;
    curated = {
      plugins: [...PLUGINS, ...(local.options?.plugins ?? [])],
      initialView: view(),
      weekends: local.weekends,
      editable: local.editable,
      selectable: local.selectable,
      height: normalizeHeight(local.height),
      locale: local.locale,
      slotDuration: local.slotDuration,
      nowIndicator: local.nowIndicator,
      // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
      // toolbar; the built-in default lives in the `headerToolbar` prop default.
      headerToolbar: local.headerToolbar,
      eventClick: (info: any) => {
        _props.onEventClick?.({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      dateClick: (info: any) => {
        _props.onDateClick?.({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay,
          dayEl: info.dayEl,
          jsEvent: info.jsEvent
        });
      },
      eventDrop: (info: any) => {
        _props.onEventDrop?.({
          event: eventRef(info.event),
          oldEvent: eventRef(info.oldEvent),
          delta: info.delta,
          revert: info.revert
        });
      },
      select: (info: any) => {
        _props.onSelect?.({
          start: info.start,
          end: info.end,
          startStr: info.startStr,
          endStr: info.endStr,
          allDay: info.allDay
        });
      },
      eventResize: (info: any) => {
        _props.onEventResize?.({
          event: eventRef(info.event),
          oldEvent: eventRef(info.oldEvent),
          startDelta: info.startDelta,
          endDelta: info.endDelta,
          revert: info.revert
        });
      },
      datesSet: (info: any) => {
        _props.onDatesSet?.({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        _props.onEventMouseEnter?.({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      eventMouseLeave: (info: any) => {
        _props.onEventMouseLeave?.({
          event: eventRef(info.event),
          jsEvent: info.jsEvent,
          el: info.el
        });
      },
      unselect: (info: any) => {
        _props.onUnselect?.({
          jsEvent: info.jsEvent
        });
      },
      loading: (isLoading: any) => {
        // FullCalendar's `loading` callback receives a bare boolean (not an info
        // object) — normalize to the structured `{ isLoading }` payload shape.
        _props.onLoading?.({
          isLoading
        });
      },
      eventsSet: (events: any) => {
        // `eventsSet` receives the array of current EventApi objects — map each to
        // the normalized floor shape for persistence/sync consumers.
        _props.onEventsSet?.({
          events: events.map(eventRef)
        });
      },
      viewDidMount: (info: any) => {
        // viewDidMount fires both on initial mount AND on changeView calls.
        // Same round-trip guard pattern as Flatpickr / LeafletMap.
        if (suppressViewSync) {
          suppressViewSync = false;
          return;
        }
        if (info.view.type !== view()) setView(info.view.type);
      },
      eventDidMount: (info: any) => {
        // Every event is focusable (the wrapper always handles eventClick), so an
        // untitled one needs an accessible name. A consumer's own
        // `options.eventDidMount` still runs, read live so updates apply.
        if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
          info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
        }
        const own = local.options?.eventDidMount;
        if (typeof own === 'function') own(info);
      }
    };
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    if (typeof local.firstDay === 'number') curated.firstDay = local.firstDay;

    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    if ((_props.eventSlot ?? _props.slots?.["event"])) {
      curated.eventContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.event(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    if ((_props.dayCellSlot ?? _props.slots?.["dayCell"])) {
      curated.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.dayHeaderSlot ?? _props.slots?.["dayHeader"])) {
      curated.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.slotLabelSlot ?? _props.slots?.["slotLabel"])) {
      curated.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.weekNumberSlot ?? _props.slots?.["weekNumber"])) {
      curated.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.nowIndicatorContentSlot ?? _props.slots?.["nowIndicatorContent"])) {
      curated.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.moreLinkSlot ?? _props.slots?.["moreLink"])) {
      curated.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.allDayContentSlot ?? _props.slots?.["allDayContent"])) {
      curated.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.slotLaneContentSlot ?? _props.slots?.["slotLaneContent"])) {
      curated.slotLaneContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLaneContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    if ((_props.noEventsContentSlot ?? _props.slots?.["noEventsContent"])) {
      curated.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }

    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    curatedKeys = new Set([...Object.keys(curated), 'events']);
    const passthrough = Object.fromEntries(Object.entries(local.options ?? {}).filter(([k]: any) => !curatedKeys.has(k)));
    appliedOptions = new Map(Object.entries(passthrough));
    instance = new Calendar(__rozieRootRef!, {
      ...passthrough,
      ...curated
    });
    eventsSource = instance.addEventSource(local.events.map(normalizeEvent));
    instance.render();
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => {
    instance?.destroy();
    instance = null;
    eventsSource = null;
  });
  });
  createEffect(on(() => (() => local.events)(), (v) => untrack(() => ((v: any) => {
    if (!instance) return;
    eventsSource?.remove();
    eventsSource = instance.addEventSource((v ?? []).map(normalizeEvent));
  })(v)), { defer: true }));
  createEffect(on(() => (() => view())(), (v) => untrack(() => ((v: any) => {
    if (!instance || !v) return;
    if (v === instance.view.type) return;
    suppressViewSync = true;
    instance.changeView(v);
  })(v)), { defer: true }));
  createEffect(on(() => (() => local.weekends)(), (v) => untrack(() => ((v: any) => instance?.setOption('weekends', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.editable)(), (v) => untrack(() => ((v: any) => instance?.setOption('editable', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.selectable)(), (v) => untrack(() => ((v: any) => instance?.setOption('selectable', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.height)(), (v) => untrack(() => ((v: any) => instance?.setOption('height', normalizeHeight(v)))(v)), { defer: true }));
  createEffect(on(() => (() => local.locale)(), (v) => untrack(() => ((v: any) => instance?.setOption('locale', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.firstDay)(), (v) => untrack(() => ((v: any) => {
    if (!instance || typeof v !== 'number') return;
    curatedKeys.add('firstDay');
    instance.setOption('firstDay', v);
  })(v)), { defer: true }));
  createEffect(on(() => (() => local.slotDuration)(), (v) => untrack(() => ((v: any) => instance?.setOption('slotDuration', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.nowIndicator)(), (v) => untrack(() => ((v: any) => instance?.setOption('nowIndicator', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.headerToolbar)(), (v) => untrack(() => ((v: any) => instance?.setOption('headerToolbar', v))(v)), { defer: true }));
  createEffect(on(() => (() => local.options)(), (v) => untrack(() => ((v: any) => {
    if (!instance || !v) return;
    for (const k in v) {
      if (curatedKeys.has(k)) continue;
      if (sameOptionValue(appliedOptions.get(k), v[k])) continue;
      appliedOptions.set(k, v[k]);
      instance.setOption(k, v[k]);
    }
  })(v)), { defer: true }));
  let __rozieRootRef: HTMLElement | null = null;

  let instance: any = null;
  let suppressViewSync = false;
  // The event source the `events` prop owns. Reconciling replaces only this
  // source, so events from `options.eventSources` or the `addEvent` verb survive.
  let eventsSource: any = null;
  // Keys the wrapper itself sets (curated props, wrapped callbacks, filled
  // *Content slots). `:options` never overrides them, at mount or at runtime.
  let curatedKeys = new Set();
  // The `:options` values last handed to FullCalendar, per key, so the runtime
  // reconcile only calls setOption for keys whose value actually changed.
  let appliedOptions = new Map();
  const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];

  // Mirrors the `height` prop default.
  const DEFAULT_HEIGHT = 480;

  // A purely numeric string height ('600') means pixels. Needed because a static
  // Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
  // the height prop) would otherwise regress `height="600"`, which the old Number
  // converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
  // passes through. An empty string, null/undefined, or a non-positive number
  // falls back to the default so every target sizes the calendar the same way.
  function normalizeHeight(h: any) {
    let v = h;
    if (typeof v === 'string') {
      const t = v.trim();
      if (t === '') return DEFAULT_HEIGHT;
      if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
    }
    if (typeof v !== 'number' || !(v > 0)) return DEFAULT_HEIGHT;
    return v;
  }
  function normalizeEvent(e: any) {
    // Object spread — common reconcile shape: pass user props through, normalize
    // the title to a string WITHOUT inventing text (internal ids must never
    // render), and honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || '',
      color: e.color || local.defaultColor
    };
  }

  // The normalized event ref every payload carries.
  function eventRef(e: any) {
    return {
      id: e.id,
      title: e.title,
      start: e.start,
      end: e.end
    };
  }

  // Structural equality for plain option values (arrays and plain objects by
  // content, everything else — functions included — by identity). Lets an inline
  // `:options` literal re-created on every parent render stay a no-op.
  function sameOptionValue(a: any, b: any) {
    if (a === b) return true;
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      for (let i = 0; i < a.length; i++) if (!sameOptionValue(a[i], b[i])) return false;
      return true;
    }
    const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
    if (isPlain(a) && isPlain(b)) {
      const ka = Object.keys(a);
      if (ka.length !== Object.keys(b).length) return false;
      for (const k of ka as any) if (!(k in b) || !sameOptionValue(a[k], b[k])) return false;
      return true;
    }
    return false;
  }
  // Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets
  // (Vue defineExpose / React useImperativeHandle / Svelte instance export /
  // Angular+Lit public method / Solid callback ref). Each delegates to the
  // underlying Calendar instance, which is null before $onMount and again after
  // unmount (destroyed and cleared) — callers handle the null.
  //
  // Collision discipline (the load-bearing flatpickr lesson): no exposed name may
  // collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
  // datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
  // a declared prop. This is why the selection verbs are NAMED `selectRange`
  // (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
  // `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
  // is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
  //
  // Read-back gap closed: getDate (current anchor — the `view` model only carries
  // the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
  // event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
  // scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
  function getApi() {
    return instance;
  }
  function changeView(...a: any[]) {
    return instance?.changeView(...a);
  }
  // Normalized like the `events` prop (title, defaultColor).
  function addEvent(event: any, source: any) {
    return instance?.addEvent(normalizeEvent(event), source);
  }
  function removeEvent(id: any) {
    instance?.getEventById(id)?.remove();
  }
  function today() {
    instance?.today();
  }
  function prev() {
    instance?.prev();
  }
  function next() {
    instance?.next();
  }
  function gotoDate(...a: any[]) {
    instance?.gotoDate(...a);
  }
  function getDate() {
    return instance ? instance.getDate() : null;
  }
  function getEvents() {
    return instance ? instance.getEvents() : [];
  }
  function scrollToTime(...a: any[]) {
    instance?.scrollToTime(...a);
  }
  function updateSize() {
    instance?.updateSize();
  }
  function prevYear() {
    instance?.prevYear();
  }
  function nextYear() {
    instance?.nextYear();
  }
  function selectRange(...a: any[]) {
    instance?.select(...a);
  }
  function clearSelection() {
    instance?.unselect();
  }

  return (
    <>
    <div class={"rozie-fullcalendar"} ref={(el) => { __rozieRootRef = el as HTMLElement; }} data-rozie-s-5589629a="" />











    </>
  );
}
ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, rozieNumberOrStringAttr } from '@rozie/runtime-lit';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

// The typed public surface (always TypeScript, whatever the script lang).
// Payload interfaces describe what the wrapper ACTUALLY emits (normalized
// `{ id, title, start, end }` event refs, the view TYPE string, `{ isLoading }`),
// not FullCalendar's raw callback args. Engine types come from the
// `@fullcalendar/core` peer and are re-exported so consumers can name them.
import type { DateInput, DateRangeInput, DateSpanInput, DurationInput, Duration, EventApi, EventInput, EventSourceApi, ViewApi, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg } from '@fullcalendar/core';
export interface FullCalendarEventRef {
  id: string;
  title: string;
  start: Date | null;
  end: Date | null;
}
/** `eventClick` payload — `jsEvent` is a `KeyboardEvent` when the event is activated with Enter/Space. */
export interface FullCalendarEventClick {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent | KeyboardEvent;
  el: HTMLElement;
}
/** `eventMouseEnter` / `eventMouseLeave` payload. */
export interface FullCalendarEventPointer {
  event: FullCalendarEventRef;
  jsEvent: MouseEvent;
  el: HTMLElement;
}
/** `dateClick` payload — `dayEl` is the clicked day cell (an anchor for a popover). */
export interface FullCalendarDateClick {
  date: Date;
  dateStr: string;
  allDay: boolean;
  dayEl: HTMLElement;
  jsEvent: MouseEvent;
}
/** `eventDrop` payload — call `revert()` to reject the move; `oldEvent` is the event before it. */
export interface FullCalendarEventDrop {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  delta: Duration;
  revert: () => void;
}
export interface FullCalendarSelection {
  start: Date;
  end: Date;
  startStr: string;
  endStr: string;
  allDay: boolean;
}
/** `eventResize` payload — call `revert()` to reject the resize; `oldEvent` is the event before it. */
export interface FullCalendarEventResize {
  event: FullCalendarEventRef;
  oldEvent: FullCalendarEventRef;
  startDelta: Duration;
  endDelta: Duration;
  revert: () => void;
}
export interface FullCalendarDatesSet {
  start: Date;
  end: Date;
  view: string;
}
/** `jsEvent` is the pointer/touch `UIEvent` that cleared the selection, or `null` when it was cleared programmatically (e.g. the `clearSelection` verb). */
export interface FullCalendarUnselect {
  jsEvent: UIEvent | null;
}
export interface FullCalendarLoading {
  isLoading: boolean;
}
export interface FullCalendarEventsSet {
  events: FullCalendarEventRef[];
}
/** `noEventsContent` arg — `@fullcalendar/list`'s NoEventsContentArg, restated so the core-only peer set suffices. */
export interface FullCalendarNoEventsContentArg {
  text: string;
  view: ViewApi;
}
export type { DateInput, EventApi, EventInput, EventContentArg, DayCellContentArg, DayHeaderContentArg, SlotLabelContentArg, WeekNumberContentArg, NowIndicatorContentArg, MoreLinkContentArg, AllDayContentArg, SlotLaneContentArg };
export type { Calendar } from '@fullcalendar/core';

export interface RozieFullCalendarEventMap extends Omit<HTMLElementEventMap, 'event-click' | 'date-click' | 'event-drop' | 'select' | 'event-resize' | 'dates-set' | 'event-mouse-enter' | 'event-mouse-leave' | 'unselect' | 'loading' | 'events-set' | 'view-change'> {
  'event-click': CustomEvent<FullCalendarEventClick>;
  'date-click': CustomEvent<FullCalendarDateClick>;
  'event-drop': CustomEvent<FullCalendarEventDrop>;
  'select': CustomEvent<FullCalendarSelection>;
  'event-resize': CustomEvent<FullCalendarEventResize>;
  'dates-set': CustomEvent<FullCalendarDatesSet>;
  'event-mouse-enter': CustomEvent<FullCalendarEventPointer>;
  'event-mouse-leave': CustomEvent<FullCalendarEventPointer>;
  'unselect': CustomEvent<FullCalendarUnselect>;
  'loading': CustomEvent<FullCalendarLoading>;
  'events-set': CustomEvent<FullCalendarEventsSet>;
  'view-change': CustomEvent<string>;
}

interface RozieEventSlotCtx {
  arg: EventContentArg;
}

interface RozieDayCellSlotCtx {
  arg: DayCellContentArg;
}

interface RozieDayHeaderSlotCtx {
  arg: DayHeaderContentArg;
}

interface RozieSlotLabelSlotCtx {
  arg: SlotLabelContentArg;
}

interface RozieWeekNumberSlotCtx {
  arg: WeekNumberContentArg;
}

interface RozieNowIndicatorContentSlotCtx {
  arg: NowIndicatorContentArg;
}

interface RozieMoreLinkSlotCtx {
  arg: MoreLinkContentArg;
}

interface RozieAllDayContentSlotCtx {
  arg: AllDayContentArg;
}

interface RozieSlotLaneContentSlotCtx {
  arg: SlotLaneContentArg;
}

interface RozieNoEventsContentSlotCtx {
  arg: FullCalendarNoEventsContentArg;
}

@customElement('rozie-full-calendar')
export default class FullCalendar extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.rozie-fullcalendar[data-rozie-s-5589629a] {
  width: 100%;
  font-size: 0.875rem;
}
`;

  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` renders as an empty title (the wrapper never invents one from the event id; an untitled event gets an `aria-label` so it still has an accessible name), and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array replaces only the events this prop supplied; events from `options.eventSources` or added through the `addEvent` verb are kept.
   */
  @property({ type: Array }) events: any[] = [];
  /**
   * The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
   * @example
   * <rozie-full-calendar .view=${view} @view-change=${…} .events=${events}></rozie-full-calendar>
   */
  @property({ type: String, attribute: 'view' }) _view_attr: string = 'dayGridMonth';
  private _viewControllable = createLitControllableProperty<string>({ host: this, eventName: 'view-change', defaultValue: 'dayGridMonth', initialControlledValue: undefined });
  /**
   * Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
   */
  @property({ type: Boolean, reflect: true }) weekends: boolean = true;
  /**
   * Allow events to be dragged and resized. Runtime-updatable via `setOption`.
   */
  @property({ type: Boolean, reflect: true }) editable: boolean = true;
  /**
   * Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
   */
  @property({ type: Boolean, reflect: true }) selectable: boolean = true;
  /**
   * The calendar height: a pixel number (`480`) or any CSS height FullCalendar accepts (`'auto'`, `'100%'`, `'32rem'`, …). A purely numeric string (`'600'`, e.g. from a static attribute) is treated as pixels. An empty string, `null`, or a number that is not positive falls back to the default `480`. This curated prop wins over `options.height` at mount and after it (`:options` never applies a curated key), so size the calendar through `height` itself. Runtime-updatable via `setOption`.
   */
  @property({ converter: { fromAttribute: rozieNumberOrStringAttr } }) height: string | number = 480;
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  @property({ type: String, reflect: true, attribute: 'default-color' }) defaultColor: string = '#3b82f6';
  /**
   * FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
   */
  @property({ type: String, reflect: true }) locale: string = 'en';
  /**
   * First day of the week (`0` = Sunday … `1` = Monday). Leave it unset (`null`, the default) to use the `locale`'s first day, e.g. Monday for `de`. Runtime-updatable via `setOption`; setting it back to `null` after mount keeps the last applied day until remount.
   */
  @property({ type: Number, reflect: true, attribute: 'first-day' }) firstDay: number | null = null;
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  @property({ type: String, reflect: true, attribute: 'slot-duration' }) slotDuration: string = '00:30:00';
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  @property({ type: Boolean, reflect: true, attribute: 'now-indicator' }) nowIndicator: boolean = false;
  /**
   * The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
   */
  @property({ type: Object, attribute: 'header-toolbar' }) headerToolbar: any = {
  left: 'prev,next today',
  center: 'title',
  right: 'dayGridMonth,timeGridWeek,timeGridDay'
};
  /**
   * Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Curated keys (the props above, `events`, every wrapped callback and filled `*Content` slot) always win: `:options` never overrides them, at mount or later. Runtime-updatable per key via `setOption`, and only for keys whose value actually changed — plain arrays and objects compare by content, so an inline literal re-created on every parent render (an inline `eventSources` list, say) does not refetch. Functions compare by identity. A removed key keeps its last applied value until remount; use `getApi()` for full imperative control. The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
   */
  @property({ type: Object }) options: any = {};
  @query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_1 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();
private portals = {
  event: (container: HTMLElement, scope: { arg: EventContentArg }): (() => void) => {
    const tpl = this.event;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-event', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  dayCell: (container: HTMLElement, scope: { arg: DayCellContentArg }): (() => void) => {
    const tpl = this.dayCell;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-dayCell', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  dayHeader: (container: HTMLElement, scope: { arg: DayHeaderContentArg }): (() => void) => {
    const tpl = this.dayHeader;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  slotLabel: (container: HTMLElement, scope: { arg: SlotLabelContentArg }): (() => void) => {
    const tpl = this.slotLabel;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  weekNumber: (container: HTMLElement, scope: { arg: WeekNumberContentArg }): (() => void) => {
    const tpl = this.weekNumber;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  nowIndicatorContent: (container: HTMLElement, scope: { arg: NowIndicatorContentArg }): (() => void) => {
    const tpl = this.nowIndicatorContent;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  moreLink: (container: HTMLElement, scope: { arg: MoreLinkContentArg }): (() => void) => {
    const tpl = this.moreLink;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-moreLink', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  allDayContent: (container: HTMLElement, scope: { arg: AllDayContentArg }): (() => void) => {
    const tpl = this.allDayContent;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  slotLaneContent: (container: HTMLElement, scope: { arg: SlotLaneContentArg }): (() => void) => {
    const tpl = this.slotLaneContent;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
  noEventsContent: (container: HTMLElement, scope: { arg: FullCalendarNoEventsContentArg }): (() => void) => {
    const tpl = this.noEventsContent;
    if (typeof tpl !== 'function') return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
    render(tpl(scope), container);
    this._portalContainers.add(container);
    return () => {
      render(nothing, container);
      this._portalContainers.delete(container);
    };
  },
};

  @state() private _hasSlotEvent = false;
  @queryAssignedElements({ slot: 'event', flatten: true }) private _slotEventElements!: Element[];
  @property({ attribute: false }) event?: (scope: { arg: EventContentArg }) => unknown;
  @state() private _hasSlotDayCell = false;
  @queryAssignedElements({ slot: 'dayCell', flatten: true }) private _slotDayCellElements!: Element[];
  @property({ attribute: false }) dayCell?: (scope: { arg: DayCellContentArg }) => unknown;
  @state() private _hasSlotDayHeader = false;
  @queryAssignedElements({ slot: 'dayHeader', flatten: true }) private _slotDayHeaderElements!: Element[];
  @property({ attribute: false }) dayHeader?: (scope: { arg: DayHeaderContentArg }) => unknown;
  @state() private _hasSlotSlotLabel = false;
  @queryAssignedElements({ slot: 'slotLabel', flatten: true }) private _slotSlotLabelElements!: Element[];
  @property({ attribute: false }) slotLabel?: (scope: { arg: SlotLabelContentArg }) => unknown;
  @state() private _hasSlotWeekNumber = false;
  @queryAssignedElements({ slot: 'weekNumber', flatten: true }) private _slotWeekNumberElements!: Element[];
  @property({ attribute: false }) weekNumber?: (scope: { arg: WeekNumberContentArg }) => unknown;
  @state() private _hasSlotNowIndicatorContent = false;
  @queryAssignedElements({ slot: 'nowIndicatorContent', flatten: true }) private _slotNowIndicatorContentElements!: Element[];
  @property({ attribute: false }) nowIndicatorContent?: (scope: { arg: NowIndicatorContentArg }) => unknown;
  @state() private _hasSlotMoreLink = false;
  @queryAssignedElements({ slot: 'moreLink', flatten: true }) private _slotMoreLinkElements!: Element[];
  @property({ attribute: false }) moreLink?: (scope: { arg: MoreLinkContentArg }) => unknown;
  @state() private _hasSlotAllDayContent = false;
  @queryAssignedElements({ slot: 'allDayContent', flatten: true }) private _slotAllDayContentElements!: Element[];
  @property({ attribute: false }) allDayContent?: (scope: { arg: AllDayContentArg }) => unknown;
  @state() private _hasSlotSlotLaneContent = false;
  @queryAssignedElements({ slot: 'slotLaneContent', flatten: true }) private _slotSlotLaneContentElements!: Element[];
  @property({ attribute: false }) slotLaneContent?: (scope: { arg: SlotLaneContentArg }) => unknown;
  @state() private _hasSlotNoEventsContent = false;
  @queryAssignedElements({ slot: 'noEventsContent', flatten: true }) private _slotNoEventsContentElements!: Element[];
  @property({ attribute: false }) noEventsContent?: (scope: { arg: FullCalendarNoEventsContentArg }) => unknown;
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
  // open-tag binding; the KEY is the fill's authored (possibly
  // non-identifier) name and the VALUE is a scope-taking render
  // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
  // named function-prop / <slot> fallback (AC-9). Attribute
  // deserialization is disabled — this is a function-valued record,
  // never reflected to/from an HTML attribute.
  @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="event"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotEvent = this._slotEventElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="dayCell"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDayCell = this._slotDayCellElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="dayHeader"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDayHeader = this._slotDayHeaderElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="slotLabel"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotSlotLabel = this._slotSlotLabelElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="weekNumber"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotWeekNumber = this._slotWeekNumberElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="nowIndicatorContent"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotNowIndicatorContent = this._slotNowIndicatorContentElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="moreLink"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotMoreLink = this._slotMoreLinkElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="allDayContent"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotAllDayContent = this._slotAllDayContentElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="slotLaneContent"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotSlotLaneContent = this._slotSlotLaneContentElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="noEventsContent"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotNoEventsContent = this._slotNoEventsContentElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotEvent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'event');
    this._hasSlotDayCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'dayCell');
    this._hasSlotDayHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'dayHeader');
    this._hasSlotSlotLabel = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slotLabel');
    this._hasSlotWeekNumber = Array.from(this.children).some((el) => el.getAttribute('slot') === 'weekNumber');
    this._hasSlotNowIndicatorContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'nowIndicatorContent');
    this._hasSlotMoreLink = Array.from(this.children).some((el) => el.getAttribute('slot') === 'moreLink');
    this._hasSlotAllDayContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'allDayContent');
    this._hasSlotSlotLaneContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slotLaneContent');
    this._hasSlotNoEventsContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'noEventsContent');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    adoptDocumentStyles(this);

    this._armListeners();

    this._disconnectCleanups.push((() => {
      this.instance?.destroy();
      this.instance = null;
      this.eventsSource = null;
    }));

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.view)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
      if (!this.instance || !v) return;
      if (v === this.instance.view.type) return;
      this.suppressViewSync = true;
      this.instance.changeView(v);
    })(__watchVal); }); }));

    // The curated config — every key the wrapper owns. The `:options`
    // passthrough below fills only the gaps (curated keys always win).
    //
    // EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
    // overrides: instead of clobbering a consumer-supplied `:options.plugins`,
    // it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
    // interaction) with any consumer-added plugins. This makes the wrapper
    // consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
    // etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
    // per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
    // consumer re-passing a default is harmless.
    // A null-let (typeNeutralize → `any` in every leaf): keys are added below
    // (`firstDay`, the filled *Content slots), which a strict object-literal type
    // would reject (TS2339).
    let curated: any = null;
    curated = {
      plugins: [...this.PLUGINS, ...(this.options?.plugins ?? [])],
      initialView: this.view,
      weekends: this.weekends,
      editable: this.editable,
      selectable: this.selectable,
      height: this.normalizeHeight(this.height),
      locale: this.locale,
      slotDuration: this.slotDuration,
      nowIndicator: this.nowIndicator,
      // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
      // toolbar; the built-in default lives in the `headerToolbar` prop default.
      headerToolbar: this.headerToolbar,
      eventClick: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarEventClick>("event-click", {
          detail: {
            event: this.eventRef(info.event),
            jsEvent: info.jsEvent,
            el: info.el
          },
          bubbles: true,
          composed: true
        }));
      },
      dateClick: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarDateClick>("date-click", {
          detail: {
            date: info.date,
            dateStr: info.dateStr,
            allDay: info.allDay,
            dayEl: info.dayEl,
            jsEvent: info.jsEvent
          },
          bubbles: true,
          composed: true
        }));
      },
      eventDrop: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarEventDrop>("event-drop", {
          detail: {
            event: this.eventRef(info.event),
            oldEvent: this.eventRef(info.oldEvent),
            delta: info.delta,
            revert: info.revert
          },
          bubbles: true,
          composed: true
        }));
      },
      select: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarSelection>("select", {
          detail: {
            start: info.start,
            end: info.end,
            startStr: info.startStr,
            endStr: info.endStr,
            allDay: info.allDay
          },
          bubbles: true,
          composed: true
        }));
      },
      eventResize: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarEventResize>("event-resize", {
          detail: {
            event: this.eventRef(info.event),
            oldEvent: this.eventRef(info.oldEvent),
            startDelta: info.startDelta,
            endDelta: info.endDelta,
            revert: info.revert
          },
          bubbles: true,
          composed: true
        }));
      },
      datesSet: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarDatesSet>("dates-set", {
          detail: {
            start: info.start,
            end: info.end,
            view: info.view.type
          },
          bubbles: true,
          composed: true
        }));
      },
      eventMouseEnter: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarEventPointer>("event-mouse-enter", {
          detail: {
            event: this.eventRef(info.event),
            jsEvent: info.jsEvent,
            el: info.el
          },
          bubbles: true,
          composed: true
        }));
      },
      eventMouseLeave: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarEventPointer>("event-mouse-leave", {
          detail: {
            event: this.eventRef(info.event),
            jsEvent: info.jsEvent,
            el: info.el
          },
          bubbles: true,
          composed: true
        }));
      },
      unselect: (info: any) => {
        this.dispatchEvent(new CustomEvent<FullCalendarUnselect>("unselect", {
          detail: {
            jsEvent: info.jsEvent
          },
          bubbles: true,
          composed: true
        }));
      },
      loading: (isLoading: any) => {
        // FullCalendar's `loading` callback receives a bare boolean (not an info
        // object) — normalize to the structured `{ isLoading }` payload shape.
        this.dispatchEvent(new CustomEvent<FullCalendarLoading>("loading", {
          detail: {
            isLoading
          },
          bubbles: true,
          composed: true
        }));
      },
      eventsSet: (events: any) => {
        // `eventsSet` receives the array of current EventApi objects — map each to
        // the normalized floor shape for persistence/sync consumers.
        this.dispatchEvent(new CustomEvent<FullCalendarEventsSet>("events-set", {
          detail: {
            events: events.map(this.eventRef)
          },
          bubbles: true,
          composed: true
        }));
      },
      viewDidMount: (info: any) => {
        // viewDidMount fires both on initial mount AND on changeView calls.
        // Same round-trip guard pattern as Flatpickr / LeafletMap.
        if (this.suppressViewSync) {
          this.suppressViewSync = false;
          return;
        }
        if (info.view.type !== this.view) this._viewControllable.write(info.view.type);
      },
      eventDidMount: (info: any) => {
        // Every event is focusable (the wrapper always handles eventClick), so an
        // untitled one needs an accessible name. A consumer's own
        // `options.eventDidMount` still runs, read live so updates apply.
        if (!info.event.title && info.el && !info.el.hasAttribute('aria-label')) {
          info.el.setAttribute('aria-label', info.timeText ? `Untitled event, ${info.timeText}` : 'Untitled event');
        }
        const own = this.options?.eventDidMount;
        if (typeof own === 'function') own(info);
      }
    };
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    // Unset (null) keeps the locale's own first day. Never pass the key empty:
    // FullCalendar's Number refiner would turn `undefined` into NaN.
    if (typeof this.firstDay === 'number') curated.firstDay = this.firstDay;

    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    // Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
    // slot, route every cell render through it. The portal helper mounts the
    // consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
    // Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
    // handle is returned to FullCalendar so it cleans up the mounted tree when
    // the cell is removed. Consumers that don't fill the slot get FullCalendar's
    // default rendering (title text) — guarded by `$slots.event`.
    if (this.event !== undefined) {
      curated.eventContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.event(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    // The 9 remaining *Content portal-slots — wired identically to `event`, one
    // per FullCalendar per-cell content hook. Each guarded by its own slot so
    // unfilled slots keep FullCalendar's default rendering (or an
    // `options.*Content` passthrough). (10 portal-slots total counting `event`
    // above; allDayContent + slotLaneContent are the two timeGrid axis/lane
    // hooks, and noEventsContent is the list-view "no events" hook — inert
    // unless the consumer engages @fullcalendar/list via :options.plugins.)
    //
    // NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
    // hook (`nowIndicatorContent`) so it does NOT clash with the boolean
    // `nowIndicator` PROP — a slot name that equals a declared prop name is now a
    // hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
    // unifies snippets and props into one `$props` namespace.
    if (this.dayCell !== undefined) {
      curated.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.dayHeader !== undefined) {
      curated.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.slotLabel !== undefined) {
      curated.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.weekNumber !== undefined) {
      curated.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.nowIndicatorContent !== undefined) {
      curated.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.moreLink !== undefined) {
      curated.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.allDayContent !== undefined) {
      curated.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.slotLaneContent !== undefined) {
      curated.slotLaneContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.slotLaneContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    // noEventsContent — the list-view "no events to display" hook. Pre-declared
    // and wired like the other 9 *Content slots, but INERT unless the consumer
    // (a) engages @fullcalendar/list via the now-merged :options.plugins AND
    // (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
    // the bundled-only plugin set there is no list view, so this hook never fires
    // — by design, documented, zero bundle cost.
    if (this.noEventsContent !== undefined) {
      curated.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = this.portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }

    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    // `events` is curated too: the prop owns its own event source (below), so an
    // `options.events` is ignored, exactly as before.
    this.curatedKeys = new Set([...Object.keys(curated), 'events']);
    const passthrough = Object.fromEntries(Object.entries(this.options ?? {}).filter(([k]: any) => !this.curatedKeys.has(k)));
    this.appliedOptions = new Map(Object.entries(passthrough));
    this.instance = new Calendar(this._ref__rozieRoot, {
      ...passthrough,
      ...curated
    });
    this.eventsSource = this.instance.addEventSource(this.events.map(this.normalizeEvent));
    this.instance.render();
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (this.__rozieFirstUpdateDone && (changedProperties.has('events'))) { const __watchVal = (() => this.events)(); ((v: any) => {
      if (!this.instance) return;
      this.eventsSource?.remove();
      this.eventsSource = this.instance.addEventSource((v ?? []).map(this.normalizeEvent));
    })(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('weekends'))) { const __watchVal = (() => this.weekends)(); ((v: any) => this.instance?.setOption('weekends', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('editable'))) { const __watchVal = (() => this.editable)(); ((v: any) => this.instance?.setOption('editable', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('selectable'))) { const __watchVal = (() => this.selectable)(); ((v: any) => this.instance?.setOption('selectable', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('height'))) { const __watchVal = (() => this.height)(); ((v: any) => this.instance?.setOption('height', this.normalizeHeight(v)))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('locale'))) { const __watchVal = (() => this.locale)(); ((v: any) => this.instance?.setOption('locale', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('firstDay'))) { const __watchVal = (() => this.firstDay)(); ((v: any) => {
      if (!this.instance || typeof v !== 'number') return;
      this.curatedKeys.add('firstDay');
      this.instance.setOption('firstDay', v);
    })(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('slotDuration'))) { const __watchVal = (() => this.slotDuration)(); ((v: any) => this.instance?.setOption('slotDuration', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('nowIndicator'))) { const __watchVal = (() => this.nowIndicator)(); ((v: any) => this.instance?.setOption('nowIndicator', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('headerToolbar'))) { const __watchVal = (() => this.headerToolbar)(); ((v: any) => this.instance?.setOption('headerToolbar', v))(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('options'))) { const __watchVal = (() => this.options)(); ((v: any) => {
      if (!this.instance || !v) return;
      for (const k in v) {
        if (this.curatedKeys.has(k)) continue;
        if (this.sameOptionValue(this.appliedOptions.get(k), v[k])) continue;
        this.appliedOptions.set(k, v[k]);
        this.instance.setOption(k, v[k]);
      }
    })(__watchVal); }
    this.__rozieFirstUpdateDone = true;
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const container of this._portalContainers) render(nothing, container);
      this._portalContainers.clear();
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'view') this._viewControllable.notifyAttributeChange(value as unknown as string);
  }

  render() {
    return html`
<div class="rozie-fullcalendar" data-rozie-ref="__rozieRoot" data-rozie-s-5589629a></div>

<slot name="event"></slot>
<slot name="dayCell"></slot>
<slot name="dayHeader"></slot>
<slot name="slotLabel"></slot>
<slot name="weekNumber"></slot>
<slot name="nowIndicatorContent"></slot>
<slot name="moreLink"></slot>
<slot name="allDayContent"></slot>
<slot name="slotLaneContent"></slot>
<slot name="noEventsContent"></slot>
`;
  }

  instance: any = null;

  suppressViewSync = false;

  // The event source the `events` prop owns. Reconciling replaces only this
  // source, so events from `options.eventSources` or the `addEvent` verb survive.
  eventsSource: any = null;

  // Keys the wrapper itself sets (curated props, wrapped callbacks, filled
  // *Content slots). `:options` never overrides them, at mount or at runtime.
  curatedKeys = new Set();

  // The `:options` values last handed to FullCalendar, per key, so the runtime
  // reconcile only calls setOption for keys whose value actually changed.
  appliedOptions = new Map();

  PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];

  // Mirrors the `height` prop default.
  DEFAULT_HEIGHT = 480;

  // A purely numeric string height ('600') means pixels. Needed because a static
  // Vue/Angular/Lit attribute arrives as a string, and Lit's String converter (see
  // the height prop) would otherwise regress `height="600"`, which the old Number
  // converter turned into 600. Any other CSS height ('auto', '100%', '32rem')
  // passes through. An empty string, null/undefined, or a non-positive number
  // falls back to the default so every target sizes the calendar the same way.
  normalizeHeight = (h: any) => {
  let v = h;
  if (typeof v === 'string') {
    const t = v.trim();
    if (t === '') return this.DEFAULT_HEIGHT;
    if (/^\d+(\.\d+)?$/.test(t)) v = Number(t);else return t;
  }
  if (typeof v !== 'number' || !(v > 0)) return this.DEFAULT_HEIGHT;
  return v;
};

  normalizeEvent = (e: any) => {
  // Object spread — common reconcile shape: pass user props through, normalize
  // the title to a string WITHOUT inventing text (internal ids must never
  // render), and honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || '',
    color: e.color || this.defaultColor
  };
};

  // The normalized event ref every payload carries.
  eventRef = (e: any) => ({
  id: e.id,
  title: e.title,
  start: e.start,
  end: e.end
});

  // Structural equality for plain option values (arrays and plain objects by
  // content, everything else — functions included — by identity). Lets an inline
  // `:options` literal re-created on every parent render stay a no-op.
  sameOptionValue = (a: any, b: any) => {
  if (a === b) return true;
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) if (!this.sameOptionValue(a[i], b[i])) return false;
    return true;
  }
  const isPlain = (o: any) => o !== null && typeof o === 'object' && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
  if (isPlain(a) && isPlain(b)) {
    const ka = Object.keys(a);
    if (ka.length !== Object.keys(b).length) return false;
    for (const k of ka as any) if (!(k in b) || !this.sameOptionValue(a[k], b[k])) return false;
    return true;
  }
  return false;
};

  // Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets
  // (Vue defineExpose / React useImperativeHandle / Svelte instance export /
  // Angular+Lit public method / Solid callback ref). Each delegates to the
  // underlying Calendar instance, which is null before $onMount and again after
  // unmount (destroyed and cleared) — callers handle the null.
  //
  // Collision discipline (the load-bearing flatpickr lesson): no exposed name may
  // collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
  // datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
  // a declared prop. This is why the selection verbs are NAMED `selectRange`
  // (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
  // `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
  // is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
  //
  // Read-back gap closed: getDate (current anchor — the `view` model only carries
  // the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
  // event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
  // scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
  getApi(): Calendar | null;
  getApi() {
    return this.instance;
  }

  changeView(viewType: string, dateOrRange?: DateRangeInput | DateInput): void;
  changeView(...a: any[]) {
    return this.instance?.changeView(...a);
  }

  // Normalized like the `events` prop (title, defaultColor).
  addEvent(event: EventInput, source?: EventSourceApi | string | boolean): EventApi | null | undefined;
  addEvent(event: any, source: any) {
    return this.instance?.addEvent(this.normalizeEvent(event), source);
  }

  removeEvent(id: string): void;
  removeEvent(id: any) {
    this.instance?.getEventById(id)?.remove();
  }

  today(): void;
  today() {
    this.instance?.today();
  }

  prev(): void;
  prev() {
    this.instance?.prev();
  }

  next(): void;
  next() {
    this.instance?.next();
  }

  gotoDate(date: DateInput): void;
  gotoDate(...a: any[]) {
    this.instance?.gotoDate(...a);
  }

  getDate(): Date | null;
  getDate() {
    return this.instance ? this.instance.getDate() : null;
  }

  getEvents(): EventApi[];
  getEvents() {
    return this.instance ? this.instance.getEvents() : [];
  }

  scrollToTime(time: DurationInput): void;
  scrollToTime(...a: any[]) {
    this.instance?.scrollToTime(...a);
  }

  updateSize(): void;
  updateSize() {
    this.instance?.updateSize();
  }

  prevYear(): void;
  prevYear() {
    this.instance?.prevYear();
  }

  nextYear(): void;
  nextYear() {
    this.instance?.nextYear();
  }

  selectRange(dateOrSpan: DateInput | DateSpanInput, end?: DateInput): void;
  selectRange(...a: any[]) {
    this.instance?.select(...a);
  }

  clearSelection(): void;
  clearSelection() {
    this.instance?.unselect();
  }

  get view(): string { return this._viewControllable.read(); }
  set view(v: string) { this._viewControllable.notifyPropertyWrite(v); }

  addEventListener<K extends keyof RozieFullCalendarEventMap>(type: K, listener: (this: FullCalendar, ev: RozieFullCalendarEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void {
    super.addEventListener(type, listener, options);
  }
  removeEventListener<K extends keyof RozieFullCalendarEventMap>(type: K, listener: (this: FullCalendar, ev: RozieFullCalendarEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void {
    super.removeEventListener(type, listener, options);
  }
}

Each is a real component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, identical on every target.

See also ​

  • FullCalendar — showcase & API — install, per-framework quick starts, the :options passthrough, the opt-in plugin model, and the full reference.
  • The portal-slot primitive — how the ten *Content render hooks route a consumer fragment through each target's imperative-render API.

Pre-1.0 — APIs may change between minor versions.