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. Everything here is driven by the same FullCalendar.rozie source that compiles to all six frameworks.

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.

One source, six outputs

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"
      @eventClick="onEventClick"
      @dateClick="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,
      view } — heavier payload shape than prior examples
    - Rich $expose imperative handle: getApi + 7 navigation/mutation verbs
-->

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

<props>
{
  events: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.',
    },
  },
  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`.',
    },
  },
  height: {
    type: Number,
    default: 480,
    docs: {
      description:
        'Calendar height in pixels. 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: 0,
    docs: {
      description:
        'First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.',
    },
  },
  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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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>

<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

const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin]

const normalizeEvent = (e) => {
  // Object spread + template-literal default — common reconcile shape:
  // pass user props through, but stamp a sensible title fallback and
  // honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || `Event ${e.id ?? '(no id)'}`,
    color: e.color || $props.defaultColor,
  }
}

$onMount(() => {
  const opts = {
    // :options passthrough spread FIRST — the curated keys below + the portal
    // *Content handlers added after this object override any colliding key, so
    // an explicitly-bound prop (e.g. :height) wins over options.height.
    //
    // 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.
    ...$props.options,
    plugins:       [...PLUGINS, ...($props.options?.plugins ?? [])],
    initialView:   $props.view,
    weekends:      $props.weekends,
    editable:      $props.editable,
    selectable:    $props.selectable,
    height:        $props.height,
    locale:        $props.locale,
    firstDay:      $props.firstDay,
    slotDuration:  $props.slotDuration,
    nowIndicator:  $props.nowIndicator,
    events:        $props.events.map(normalizeEvent),
    // 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:   { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
        jsEvent: info.jsEvent,
      })
    },
    dateClick: (info) => {
      $emit('dateClick', { date: info.date, dateStr: info.dateStr, allDay: info.allDay })
    },
    eventDrop: (info) => {
      $emit('eventDrop', {
        event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
        delta: info.delta,
      })
    },
    select: (info) => {
      $emit('select', { start: info.start, end: info.end, startStr: info.startStr, endStr: info.endStr, allDay: info.allDay })
    },
    eventResize: (info) => {
      $emit('eventResize', {
        event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
        startDelta: info.startDelta,
        endDelta:   info.endDelta,
      })
    },
    datesSet: (info) => {
      $emit('datesSet', { start: info.start, end: info.end, view: info.view.type })
    },
    eventMouseEnter: (info) => {
      $emit('eventMouseEnter', {
        event:   { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
        jsEvent: info.jsEvent,
      })
    },
    eventMouseLeave: (info) => {
      $emit('eventMouseLeave', {
        event:   { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
        jsEvent: info.jsEvent,
      })
    },
    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((e) => ({ id: e.id, title: e.title, start: e.start, end: e.end })) })
    },
    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
    },
  }

  // 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) {
    opts.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. (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) {
    opts.dayCellContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.dayCell(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.dayHeader) {
    opts.dayHeaderContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.dayHeader(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.slotLabel) {
    opts.slotLabelContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.slotLabel(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.weekNumber) {
    opts.weekNumberContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.weekNumber(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.nowIndicatorContent) {
    opts.nowIndicatorContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.nowIndicatorContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.moreLink) {
    opts.moreLinkContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.moreLink(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.allDayContent) {
    opts.allDayContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.allDayContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }
  if ($slots.slotLaneContent) {
    opts.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) {
    opts.noEventsContent = (arg) => {
      const node = document.createElement('div')
      const dispose = $portals.noEventsContent(node, { arg })
      return { domNodes: [node], dispose }
    }
  }

  instance = new Calendar($el, opts)
  instance.render()
  return () => instance?.destroy()
})

// Reconcile events without remounting: removeAllEvents + addEvent loop is
// FullCalendar's supported runtime-update path. setOption('events', …)
// also works but resets internal indexing.
$watch(() => $props.events, (v) => {
  if (!instance) return
  instance.removeAllEvents()
  for (const e of v) instance.addEvent(normalizeEvent(e))
})

$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',     v))
$watch(() => $props.locale,        (v) => instance?.setOption('locale',        v))
$watch(() => $props.firstDay,      (v) => 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 passthrough key through setOption.
// 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) return
  for (const k in v) 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 after
// destroy — callers handle the pre-mount 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) }
function addEvent(...a)   { return instance?.addEvent(...a) }
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,
})
</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']" />
<slot name="dayCell" portal :params="['arg']" />
<slot name="dayHeader" portal :params="['arg']" />
<slot name="slotLabel" portal :params="['arg']" />
<slot name="weekNumber" portal :params="['arg']" />
<slot name="nowIndicatorContent" portal :params="['arg']" />
<slot name="moreLink" portal :params="['arg']" />
<slot name="allDayContent" portal :params="['arg']" />
<slot name="slotLaneContent" portal :params="['arg']" />
<slot name="noEventsContent" portal :params="['arg']" />
</template>

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

</rozie>

…and Rozie compiles it to six idiomatic, 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';

interface EventCtx { arg: any; }

interface DayCellCtx { arg: any; }

interface DayHeaderCtx { arg: any; }

interface SlotLabelCtx { arg: any; }

interface WeekNumberCtx { arg: any; }

interface NowIndicatorContentCtx { arg: any; }

interface MoreLinkCtx { arg: any; }

interface AllDayContentCtx { arg: any; }

interface SlotLaneContentCtx { arg: any; }

interface NoEventsContentCtx { arg: any; }

interface FullCalendarProps {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
   */
  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 r-model:view="view" :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;
  /**
   * Calendar height in pixels. Runtime-updatable via `setOption`.
   */
  height?: 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). Runtime-updatable via `setOption`.
   */
  firstDay?: number;
  /**
   * 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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?: (...args: any[]) => void;
  onDateClick?: (...args: any[]) => void;
  onEventDrop?: (...args: any[]) => void;
  onSelect?: (...args: any[]) => void;
  onEventResize?: (...args: any[]) => void;
  onDatesSet?: (...args: any[]) => void;
  onEventMouseEnter?: (...args: any[]) => void;
  onEventMouseLeave?: (...args: any[]) => void;
  onUnselect?: (...args: any[]) => void;
  onLoading?: (...args: any[]) => void;
  onEventsSet?: (...args: any[]) => 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: (...args: any[]) => any;
  changeView: (...args: any[]) => any;
  addEvent: (...args: any[]) => any;
  removeEvent: (...args: any[]) => any;
  today: (...args: any[]) => any;
  prev: (...args: any[]) => any;
  next: (...args: any[]) => any;
  gotoDate: (...args: any[]) => any;
  getDate: (...args: any[]) => any;
  getEvents: (...args: any[]) => any;
  scrollToTime: (...args: any[]) => any;
  updateSize: (...args: any[]) => any;
  prevYear: (...args: any[]) => any;
  nextYear: (...args: any[]) => any;
  selectRange: (...args: any[]) => any;
  clearSelection: (...args: any[]) => any;
}

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: number; defaultColor: string; locale: string; firstDay: number; 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 ?? 0,
    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 suppressViewSync = useRef(false);
  const instance = 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 _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);

  const PLUGINS = useMemo(() => [dayGridPlugin, timeGridPlugin, interactionPlugin], []);
  const normalizeEvent = useCallback((e: any) => {
    // Object spread + template-literal default — common reconcile shape:
    // pass user props through, but stamp a sensible title fallback and
    // honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || `Event ${e.id ?? '(no id)'}`,
      color: e.color || props.defaultColor
    };
  }, [props.defaultColor]);
  // 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 after
  // destroy — callers handle the pre-mount 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);
  }
  function addEvent(...a: any[]) {
    return instance.current?.addEvent(...a);
  }
  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(() => {
    const portals = {
    event: (container: HTMLElement, scope: { arg: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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 opts: Record<string, any> = {
      // :options passthrough spread FIRST — the curated keys below + the portal
      // *Content handlers added after this object override any colliding key, so
      // an explicitly-bound prop (e.g. :height) wins over options.height.
      //
      // 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.
      ..._optionsRef.current,
      plugins: [...PLUGINS, ...(_optionsRef.current?.plugins ?? [])],
      initialView: _viewRef.current,
      weekends: _weekendsRef.current,
      editable: _editableRef.current,
      selectable: _selectableRef.current,
      height: _heightRef.current,
      locale: _localeRef.current,
      firstDay: _firstDayRef.current,
      slotDuration: _slotDurationRef.current,
      nowIndicator: _nowIndicatorRef.current,
      events: _eventsRef.current.map(normalizeEvent),
      // 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) => {
        props.onEventClick && props.onEventClick({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      dateClick: (info: any) => {
        props.onDateClick && props.onDateClick({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay
        });
      },
      eventDrop: (info: any) => {
        props.onEventDrop && props.onEventDrop({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          delta: info.delta
        });
      },
      select: (info: any) => {
        props.onSelect && props.onSelect({
          start: info.start,
          end: info.end,
          startStr: info.startStr,
          endStr: info.endStr,
          allDay: info.allDay
        });
      },
      eventResize: (info: any) => {
        props.onEventResize && props.onEventResize({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          startDelta: info.startDelta,
          endDelta: info.endDelta
        });
      },
      datesSet: (info: any) => {
        props.onDatesSet && props.onDatesSet({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        props.onEventMouseEnter && props.onEventMouseEnter({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      eventMouseLeave: (info: any) => {
        props.onEventMouseLeave && props.onEventMouseLeave({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      unselect: (info: any) => {
        props.onUnselect && 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 && 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 && props.onEventsSet({
          events: events.map((e: any) => ({
            id: e.id,
            title: e.title,
            start: e.start,
            end: e.end
          }))
        });
      },
      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);
      }
    };

    // 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"])) {
      opts.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. (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"])) {
      opts.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderDayHeader ?? props.slots?.["dayHeader"])) {
      opts.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderSlotLabel ?? props.slots?.["slotLabel"])) {
      opts.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderWeekNumber ?? props.slots?.["weekNumber"])) {
      opts.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderNowIndicatorContent ?? props.slots?.["nowIndicatorContent"])) {
      opts.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderMoreLink ?? props.slots?.["moreLink"])) {
      opts.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderAllDayContent ?? props.slots?.["allDayContent"])) {
      opts.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((props.renderSlotLaneContent ?? props.slots?.["slotLaneContent"])) {
      opts.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"])) {
      opts.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    instance.current = new Calendar(__rozieRoot.current!, opts);
    instance.current.render();
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      instance.current?.destroy();
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const v = props.events;
    if (!instance.current) return;
    instance.current.removeAllEvents();
    for (const e of v as any) instance.current.addEvent(normalizeEvent(e));
  }, [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', v);
  }, [props.height]);
  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;
    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) return;
    for (const k in v) instance.current.setOption(k, v[k]);
  }, [props.options]);

  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 setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
     */
    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;
    /**
     * Calendar height in pixels. Runtime-updatable via `setOption`.
     */
    height?: 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). Runtime-updatable via `setOption`.
     */
    firstDay?: number;
    /**
     * 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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: 0, 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 r-model:view="view" :events="events" />
 */
const view = defineModel<string>('view', { default: 'dayGridMonth' });

const emit = defineEmits<{
  eventClick: [...args: any[]];
  dateClick: [...args: any[]];
  eventDrop: [...args: any[]];
  select: [...args: any[]];
  eventResize: [...args: any[]];
  datesSet: [...args: any[]];
  eventMouseEnter: [...args: any[]];
  eventMouseLeave: [...args: any[]];
  unselect: [...args: any[]];
  loading: [...args: any[]];
  eventsSet: [...args: any[]];
}>();

defineSlots<{
  event(props: { arg: any }): any;
  dayCell(props: { arg: any }): any;
  dayHeader(props: { arg: any }): any;
  slotLabel(props: { arg: any }): any;
  weekNumber(props: { arg: any }): any;
  nowIndicatorContent(props: { arg: any }): any;
  moreLink(props: { arg: any }): any;
  allDayContent(props: { arg: any }): any;
  slotLaneContent(props: { arg: any }): any;
  noEventsContent(props: { arg: any }): any;
}>();

const slots = useSlots();

const __rozieRootRef = ref<HTMLElement>();

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;
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
const normalizeEvent = (e: any) => {
  // Object spread + template-literal default — common reconcile shape:
  // pass user props through, but stamp a sensible title fallback and
  // honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || `Event ${e.id ?? '(no id)'}`,
    color: e.color || props.defaultColor
  };
};
// 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 after
// destroy — callers handle the pre-mount 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);
}
function addEvent(...a: any[]) {
  return instance?.addEvent(...a);
}
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();
}

const portalContainers = new Set<HTMLElement>();
const portals = {
  event: (container: HTMLElement, scope: { arg: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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 _cleanup_0: (() => void) | undefined;
onMounted(() => {
  const opts: Record<string, any> = {
    // :options passthrough spread FIRST — the curated keys below + the portal
    // *Content handlers added after this object override any colliding key, so
    // an explicitly-bound prop (e.g. :height) wins over options.height.
    //
    // 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.
    ...props.options,
    plugins: [...PLUGINS, ...(props.options?.plugins ?? [])],
    initialView: view.value,
    weekends: props.weekends,
    editable: props.editable,
    selectable: props.selectable,
    height: props.height,
    locale: props.locale,
    firstDay: props.firstDay,
    slotDuration: props.slotDuration,
    nowIndicator: props.nowIndicator,
    events: props.events.map(normalizeEvent),
    // 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: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    dateClick: (info: any) => {
      emit('dateClick', {
        date: info.date,
        dateStr: info.dateStr,
        allDay: info.allDay
      });
    },
    eventDrop: (info: any) => {
      emit('eventDrop', {
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        delta: info.delta
      });
    },
    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: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        startDelta: info.startDelta,
        endDelta: info.endDelta
      });
    },
    datesSet: (info: any) => {
      emit('datesSet', {
        start: info.start,
        end: info.end,
        view: info.view.type
      });
    },
    eventMouseEnter: (info: any) => {
      emit('eventMouseEnter', {
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    eventMouseLeave: (info: any) => {
      emit('eventMouseLeave', {
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    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((e: any) => ({
          id: e.id,
          title: e.title,
          start: e.start,
          end: e.end
        }))
      });
    },
    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;
    }
  };

  // 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) {
    opts.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. (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) {
    opts.dayCellContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayCell(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.dayHeader) {
    opts.dayHeaderContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayHeader(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.slotLabel) {
    opts.slotLabelContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLabel(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.weekNumber) {
    opts.weekNumberContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.weekNumber(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.nowIndicatorContent) {
    opts.nowIndicatorContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.nowIndicatorContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.moreLink) {
    opts.moreLinkContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.moreLink(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.allDayContent) {
    opts.allDayContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.allDayContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slots.slotLaneContent) {
    opts.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) {
    opts.noEventsContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.noEventsContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  instance = new Calendar(__rozieRootRef.value!, opts);
  instance.render();
  _cleanup_0 = () => instance?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => props.events, (v: any) => {
  if (!instance) return;
  instance.removeAllEvents();
  for (const e of v as any) instance.addEvent(normalizeEvent(e));
});
watch(() => view.value, (v: any) => {
  if (!instance || !v) return;
  if (v === instance.view.type) return;
  suppressViewSync = true;
  instance.changeView(v);
});
watch(() => props.weekends, (v: any) => instance?.setOption('weekends', v));
watch(() => props.editable, (v: any) => instance?.setOption('editable', v));
watch(() => props.selectable, (v: any) => instance?.setOption('selectable', v));
watch(() => props.height, (v: any) => instance?.setOption('height', v));
watch(() => props.locale, (v: any) => instance?.setOption('locale', v));
watch(() => props.firstDay, (v: any) => instance?.setOption('firstDay', v));
watch(() => props.slotDuration, (v: any) => instance?.setOption('slotDuration', v));
watch(() => props.nowIndicator, (v: any) => instance?.setOption('nowIndicator', v));
watch(() => props.headerToolbar, (v: any) => instance?.setOption('headerToolbar', v));
watch(() => props.options, (v: any) => {
  if (!instance) return;
  for (const k in v) instance.setOption(k, v[k]);
});

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

<style scoped>
.rozie-fullcalendar {
  width: 100%;
  font-size: 0.875rem;
}
</style>
svelte
<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` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
   */
  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 r-model:view="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;
  /**
   * Calendar height in pixels. Runtime-updatable via `setOption`.
   */
  height?: 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). Runtime-updatable via `setOption`.
   */
  firstDay?: number;
  /**
   * 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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: any }]>;
  dayCell?: Snippet<[{ arg: any }]>;
  dayHeader?: Snippet<[{ arg: any }]>;
  slotLabel?: Snippet<[{ arg: any }]>;
  weekNumber?: Snippet<[{ arg: any }]>;
  nowIndicatorContent?: Snippet<[{ arg: any }]>;
  moreLink?: Snippet<[{ arg: any }]>;
  allDayContent?: Snippet<[{ arg: any }]>;
  slotLaneContent?: Snippet<[{ arg: any }]>;
  noEventsContent?: Snippet<[{ arg: any }]>;
  snippets?: Record<string, any>;
  oneventclick?: (...args: unknown[]) => void;
  ondateclick?: (...args: unknown[]) => void;
  oneventdrop?: (...args: unknown[]) => void;
  onselect?: (...args: unknown[]) => void;
  oneventresize?: (...args: unknown[]) => void;
  ondatesset?: (...args: unknown[]) => void;
  oneventmouseenter?: (...args: unknown[]) => void;
  oneventmouseleave?: (...args: unknown[]) => void;
  onunselect?: (...args: unknown[]) => void;
  onloading?: (...args: unknown[]) => void;
  oneventsset?: (...args: unknown[]) => 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 = 0,
  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);

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;
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
const normalizeEvent = (e: any) => {
  // Object spread + template-literal default — common reconcile shape:
  // pass user props through, but stamp a sensible title fallback and
  // honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || `Event ${e.id ?? '(no id)'}`,
    color: e.color || defaultColor
  };
};
// 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 after
// destroy — callers handle the pre-mount 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(...a: any[]) {
  return instance?.changeView(...a);
}
export function addEvent(...a: any[]) {
  return instance?.addEvent(...a);
}
export function removeEvent(id: any) {
  instance?.getEventById(id)?.remove();
}
export function today() {
  instance?.today();
}
export function prev() {
  instance?.prev();
}
export function next() {
  instance?.next();
}
export function gotoDate(...a: any[]) {
  instance?.gotoDate(...a);
}
export function getDate() {
  return instance ? instance.getDate() : null;
}
export function getEvents() {
  return instance ? instance.getEvents() : [];
}
export function scrollToTime(...a: any[]) {
  instance?.scrollToTime(...a);
}
export function updateSize() {
  instance?.updateSize();
}
export function prevYear() {
  instance?.prevYear();
}
export function nextYear() {
  instance?.nextYear();
}
export function selectRange(...a: any[]) {
  instance?.select(...a);
}
export function clearSelection() {
  instance?.unselect();
}

const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  event: (container: HTMLElement, scope: { arg: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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();
});

onMount(() => {
  const opts = {
    // :options passthrough spread FIRST — the curated keys below + the portal
    // *Content handlers added after this object override any colliding key, so
    // an explicitly-bound prop (e.g. :height) wins over options.height.
    //
    // 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.
    ...options,
    plugins: [...PLUGINS, ...(options?.plugins ?? [])],
    initialView: view,
    weekends: weekends,
    editable: editable,
    selectable: selectable,
    height: height,
    locale: locale,
    firstDay: firstDay,
    slotDuration: slotDuration,
    nowIndicator: nowIndicator,
    events: events.map(normalizeEvent),
    // 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: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    dateClick: (info: any) => {
      ondateclick?.({
        date: info.date,
        dateStr: info.dateStr,
        allDay: info.allDay
      });
    },
    eventDrop: (info: any) => {
      oneventdrop?.({
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        delta: info.delta
      });
    },
    select: (info: any) => {
      onselect?.({
        start: info.start,
        end: info.end,
        startStr: info.startStr,
        endStr: info.endStr,
        allDay: info.allDay
      });
    },
    eventResize: (info: any) => {
      oneventresize?.({
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        startDelta: info.startDelta,
        endDelta: info.endDelta
      });
    },
    datesSet: (info: any) => {
      ondatesset?.({
        start: info.start,
        end: info.end,
        view: info.view.type
      });
    },
    eventMouseEnter: (info: any) => {
      oneventmouseenter?.({
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    eventMouseLeave: (info: any) => {
      oneventmouseleave?.({
        event: {
          id: info.event.id,
          title: info.event.title,
          start: info.event.start,
          end: info.event.end
        },
        jsEvent: info.jsEvent
      });
    },
    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((e: any) => ({
          id: e.id,
          title: e.title,
          start: e.start,
          end: e.end
        }))
      });
    },
    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;
    }
  };

  // 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) {
    opts.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. (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) {
    opts.dayCellContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayCell(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (dayHeader) {
    opts.dayHeaderContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.dayHeader(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slotLabel) {
    opts.slotLabelContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.slotLabel(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (weekNumber) {
    opts.weekNumberContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.weekNumber(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (nowIndicatorContent) {
    opts.nowIndicatorContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.nowIndicatorContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (moreLink) {
    opts.moreLinkContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.moreLink(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (allDayContent) {
    opts.allDayContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.allDayContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  if (slotLaneContent) {
    opts.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) {
    opts.noEventsContent = (arg: any) => {
      const node = document.createElement('div');
      const dispose = portals.noEventsContent(node, {
        arg
      });
      return {
        domNodes: [node],
        dispose
      };
    };
  }
  instance = new Calendar(__rozieRoot!, opts);
  instance.render();
  return () => instance?.destroy();
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => events)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
  if (!instance) return;
  instance.removeAllEvents();
  for (const e of v as any) instance.addEvent(normalizeEvent(e));
})(__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', 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) => 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) return;
  for (const k in v) 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, contentChild, effect, inject, input, model, output, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

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

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

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

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

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

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

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

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

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

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

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

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

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











    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    .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` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
   */
  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
   * <FullCalendar r-model: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);
  /**
   * Calendar height in pixels. Runtime-updatable via `setOption`.
   */
  height = input<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). Runtime-updatable via `setOption`.
   */
  firstDay = input<number>(0);
  /**
   * 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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<unknown>();
  dateClick = output<unknown>();
  eventDrop = output<unknown>();
  select = output<unknown>();
  eventResize = output<unknown>();
  datesSet = output<unknown>();
  eventMouseEnter = output<unknown>();
  eventMouseLeave = output<unknown>();
  unselect = output<unknown>();
  loading = output<unknown>();
  eventsSet = output<unknown>();
  @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);
  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 __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.instance.removeAllEvents();
      for (const e of v as any) this.instance.addEvent(this.normalizeEvent(e));
    })(__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', 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) => 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) return;
      for (const k in v) this.instance.setOption(k, v[k]);
    })(__watchVal); }); });
  }

  ngAfterViewInit() {
    const portals = {
      event: (container: HTMLElement, scope: { arg: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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>);
        };
      },
    };
    const __options = this.options();
    const opts: Record<string, any> = {
      // :options passthrough spread FIRST — the curated keys below + the portal
      // *Content handlers added after this object override any colliding key, so
      // an explicitly-bound prop (e.g. :height) wins over options.height.
      //
      // 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.
      ...__options,
      plugins: [...this.PLUGINS, ...(__options?.plugins ?? [])],
      initialView: this.view(),
      weekends: this.weekends(),
      editable: this.editable(),
      selectable: this.selectable(),
      height: this.height(),
      locale: this.locale(),
      firstDay: this.firstDay(),
      slotDuration: this.slotDuration(),
      nowIndicator: this.nowIndicator(),
      events: this.events().map(this.normalizeEvent),
      // 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: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      dateClick: (info: any) => {
        this.dateClick.emit({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay
        });
      },
      eventDrop: (info: any) => {
        this.eventDrop.emit({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          delta: info.delta
        });
      },
      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: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          startDelta: info.startDelta,
          endDelta: info.endDelta
        });
      },
      datesSet: (info: any) => {
        this.datesSet.emit({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        this.eventMouseEnter.emit({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      eventMouseLeave: (info: any) => {
        this.eventMouseLeave.emit({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      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((e: any) => ({
            id: e.id,
            title: e.title,
            start: e.start,
            end: e.end
          }))
        });
      },
      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);
      }
    };

    // 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.templates()?.['event'])) {
      opts.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. (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. (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.templates()?.['dayCell'])) {
      opts.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.dayHeaderTpl ?? this.templates()?.['dayHeader'])) {
      opts.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.slotLabelTpl ?? this.templates()?.['slotLabel'])) {
      opts.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.weekNumberTpl ?? this.templates()?.['weekNumber'])) {
      opts.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.nowIndicatorContentTpl ?? this.templates()?.['nowIndicatorContent'])) {
      opts.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.moreLinkTpl ?? this.templates()?.['moreLink'])) {
      opts.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.allDayContentTpl ?? this.templates()?.['allDayContent'])) {
      opts.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((this.slotLaneContentTpl ?? this.templates()?.['slotLaneContent'])) {
      opts.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.
    // 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.templates()?.['noEventsContent'])) {
      opts.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    this.instance = new Calendar(this.__rozieRoot()!.nativeElement, opts);
    this.instance.render();
    this.__rozieDestroyRef.onDestroy(() => this.instance?.destroy());
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  instance: any = null;
  suppressViewSync = false;
  PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
  normalizeEvent = (e: any) => {
    // Object spread + template-literal default — common reconcile shape:
    // pass user props through, but stamp a sensible title fallback and
    // honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || `Event ${e.id ?? '(no id)'}`,
      color: e.color || this.defaultColor()
    };
  };
  getApi = () => {
    return this.instance;
  };
  changeView = (...a: any[]) => {
    return this.instance?.changeView(...a);
  };
  addEvent = (...a: any[]) => {
    return this.instance?.addEvent(...a);
  };
  removeEvent = (id: any) => {
    this.instance?.getEventById(id)?.remove();
  };
  today = () => {
    this.instance?.today();
  };
  prev = () => {
    this.instance?.prev();
  };
  next = () => {
    this.instance?.next();
  };
  gotoDate = (...a: any[]) => {
    this.instance?.gotoDate(...a);
  };
  getDate = () => {
    return this.instance ? this.instance.getDate() : null;
  };
  getEvents = () => {
    return this.instance ? this.instance.getEvents() : [];
  };
  scrollToTime = (...a: any[]) => {
    this.instance?.scrollToTime(...a);
  };
  updateSize = () => {
    this.instance?.updateSize();
  };
  prevYear = () => {
    this.instance?.prevYear();
  };
  nextYear = () => {
    this.instance?.nextYear();
  };
  selectRange = (...a: any[]) => {
    this.instance?.select(...a);
  };
  clearSelection = () => {
    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';

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

interface EventSlotCtx { arg: any; }

interface DayCellSlotCtx { arg: any; }

interface DayHeaderSlotCtx { arg: any; }

interface SlotLabelSlotCtx { arg: any; }

interface WeekNumberSlotCtx { arg: any; }

interface NowIndicatorContentSlotCtx { arg: any; }

interface MoreLinkSlotCtx { arg: any; }

interface AllDayContentSlotCtx { arg: any; }

interface SlotLaneContentSlotCtx { arg: any; }

interface NoEventsContentSlotCtx { arg: any; }

interface FullCalendarProps {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
   */
  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 r-model:view="view" :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;
  /**
   * Calendar height in pixels. Runtime-updatable via `setOption`.
   */
  height?: 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). Runtime-updatable via `setOption`.
   */
  firstDay?: number;
  /**
   * 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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?: (...args: unknown[]) => void;
  onDateClick?: (...args: unknown[]) => void;
  onEventDrop?: (...args: unknown[]) => void;
  onSelect?: (...args: unknown[]) => void;
  onEventResize?: (...args: unknown[]) => void;
  onDatesSet?: (...args: unknown[]) => void;
  onEventMouseEnter?: (...args: unknown[]) => void;
  onEventMouseLeave?: (...args: unknown[]) => void;
  onUnselect?: (...args: unknown[]) => void;
  onLoading?: (...args: unknown[]) => void;
  onEventsSet?: (...args: unknown[]) => 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: (...args: any[]) => any;
  changeView: (...args: any[]) => any;
  addEvent: (...args: any[]) => any;
  removeEvent: (...args: any[]) => any;
  today: (...args: any[]) => any;
  prev: (...args: any[]) => any;
  next: (...args: any[]) => any;
  gotoDate: (...args: any[]) => any;
  getDate: (...args: any[]) => any;
  getEvents: (...args: any[]) => any;
  scrollToTime: (...args: any[]) => any;
  updateSize: (...args: any[]) => any;
  prevYear: (...args: any[]) => any;
  nextYear: (...args: any[]) => any;
  selectRange: (...args: any[]) => any;
  clearSelection: (...args: any[]) => any;
}

export default function FullCalendar(_props: FullCalendarProps): JSX.Element {
  const _merged = mergeProps({ events: (() => [])(), weekends: true, editable: true, selectable: true, height: 480, defaultColor: '#3b82f6', locale: 'en', firstDay: 0, slotDuration: '00:30:00', nowIndicator: false, headerToolbar: (() => ({
  left: 'prev,next today',
  center: 'title',
  right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))(), options: (() => ({}))() }, _props);
  const [local, attrs] = splitProps(_merged, ['events', 'view', 'weekends', 'editable', 'selectable', 'height', 'defaultColor', 'locale', 'firstDay', 'slotDuration', 'nowIndicator', 'headerToolbar', 'options', 'ref']);
  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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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 = (() => {
    const opts: Record<string, any> = {
      // :options passthrough spread FIRST — the curated keys below + the portal
      // *Content handlers added after this object override any colliding key, so
      // an explicitly-bound prop (e.g. :height) wins over options.height.
      //
      // 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.
      ...local.options,
      plugins: [...PLUGINS, ...(local.options?.plugins ?? [])],
      initialView: view(),
      weekends: local.weekends,
      editable: local.editable,
      selectable: local.selectable,
      height: local.height,
      locale: local.locale,
      firstDay: local.firstDay,
      slotDuration: local.slotDuration,
      nowIndicator: local.nowIndicator,
      events: local.events.map(normalizeEvent),
      // 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: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      dateClick: (info: any) => {
        _props.onDateClick?.({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay
        });
      },
      eventDrop: (info: any) => {
        _props.onEventDrop?.({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          delta: info.delta
        });
      },
      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: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          startDelta: info.startDelta,
          endDelta: info.endDelta
        });
      },
      datesSet: (info: any) => {
        _props.onDatesSet?.({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        _props.onEventMouseEnter?.({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      eventMouseLeave: (info: any) => {
        _props.onEventMouseLeave?.({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      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((e: any) => ({
            id: e.id,
            title: e.title,
            start: e.start,
            end: e.end
          }))
        });
      },
      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);
      }
    };

    // 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"])) {
      opts.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. (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"])) {
      opts.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.dayHeaderSlot ?? _props.slots?.["dayHeader"])) {
      opts.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.slotLabelSlot ?? _props.slots?.["slotLabel"])) {
      opts.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.weekNumberSlot ?? _props.slots?.["weekNumber"])) {
      opts.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.nowIndicatorContentSlot ?? _props.slots?.["nowIndicatorContent"])) {
      opts.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.moreLinkSlot ?? _props.slots?.["moreLink"])) {
      opts.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.allDayContentSlot ?? _props.slots?.["allDayContent"])) {
      opts.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if ((_props.slotLaneContentSlot ?? _props.slots?.["slotLaneContent"])) {
      opts.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"])) {
      opts.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    instance = new Calendar(__rozieRootRef!, opts);
    instance.render();
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => instance?.destroy());
  });
  createEffect(on(() => (() => local.events)(), (v) => untrack(() => ((v: any) => {
    if (!instance) return;
    instance.removeAllEvents();
    for (const e of v as any) instance.addEvent(normalizeEvent(e));
  })(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', 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) => 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) return;
    for (const k in v) instance.setOption(k, v[k]);
  })(v)), { defer: true }));
  let __rozieRootRef: HTMLElement | null = null;

  let instance: any = null;
  let suppressViewSync = false;
  const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
  function normalizeEvent(e: any) {
    // Object spread + template-literal default — common reconcile shape:
    // pass user props through, but stamp a sensible title fallback and
    // honor the wrapper's defaultColor only when the event omits one.
    return {
      ...e,
      title: e.title || `Event ${e.id ?? '(no id)'}`,
      color: e.color || local.defaultColor
    };
  }
  // 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 after
  // destroy — callers handle the pre-mount 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);
  }
  function addEvent(...a: any[]) {
    return instance?.addEvent(...a);
  }
  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 } from '@rozie/runtime-lit';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

interface RozieEventSlotCtx {
  arg: unknown;
}

interface RozieDayCellSlotCtx {
  arg: unknown;
}

interface RozieDayHeaderSlotCtx {
  arg: unknown;
}

interface RozieSlotLabelSlotCtx {
  arg: unknown;
}

interface RozieWeekNumberSlotCtx {
  arg: unknown;
}

interface RozieNowIndicatorContentSlotCtx {
  arg: unknown;
}

interface RozieMoreLinkSlotCtx {
  arg: unknown;
}

interface RozieAllDayContentSlotCtx {
  arg: unknown;
}

interface RozieSlotLaneContentSlotCtx {
  arg: unknown;
}

interface RozieNoEventsContentSlotCtx {
  arg: unknown;
}

@customElement('rozie-full-calendar')
export default class FullCalendar extends SignalWatcher(LitElement) {
  static styles = css`
.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` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
   */
  @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
   * <FullCalendar r-model:view="view" :events="events" />
   */
  @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;
  /**
   * Calendar height in pixels. Runtime-updatable via `setOption`.
   */
  @property({ type: Number, reflect: true }) height: number = 480;
  /**
   * Fallback event color stamped onto events that omit their own `color`.
   */
  @property({ type: String, reflect: true }) 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). Runtime-updatable via `setOption`.
   */
  @property({ type: Number, reflect: true }) firstDay: number = 0;
  /**
   * Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
   */
  @property({ type: String, reflect: true }) slotDuration: string = '00:30:00';
  /**
   * Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
   */
  @property({ type: Boolean, reflect: true }) 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 }) 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, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — 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>();

  @state() private _hasSlotEvent = false;
  @queryAssignedElements({ slot: 'event', flatten: true }) private _slotEventElements!: Element[];
  @property({ attribute: false }) event?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotDayCell = false;
  @queryAssignedElements({ slot: 'dayCell', flatten: true }) private _slotDayCellElements!: Element[];
  @property({ attribute: false }) dayCell?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotDayHeader = false;
  @queryAssignedElements({ slot: 'dayHeader', flatten: true }) private _slotDayHeaderElements!: Element[];
  @property({ attribute: false }) dayHeader?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotSlotLabel = false;
  @queryAssignedElements({ slot: 'slotLabel', flatten: true }) private _slotSlotLabelElements!: Element[];
  @property({ attribute: false }) slotLabel?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotWeekNumber = false;
  @queryAssignedElements({ slot: 'weekNumber', flatten: true }) private _slotWeekNumberElements!: Element[];
  @property({ attribute: false }) weekNumber?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotNowIndicatorContent = false;
  @queryAssignedElements({ slot: 'nowIndicatorContent', flatten: true }) private _slotNowIndicatorContentElements!: Element[];
  @property({ attribute: false }) nowIndicatorContent?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotMoreLink = false;
  @queryAssignedElements({ slot: 'moreLink', flatten: true }) private _slotMoreLinkElements!: Element[];
  @property({ attribute: false }) moreLink?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotAllDayContent = false;
  @queryAssignedElements({ slot: 'allDayContent', flatten: true }) private _slotAllDayContentElements!: Element[];
  @property({ attribute: false }) allDayContent?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotSlotLaneContent = false;
  @queryAssignedElements({ slot: 'slotLaneContent', flatten: true }) private _slotSlotLaneContentElements!: Element[];
  @property({ attribute: false }) slotLaneContent?: (scope: { arg: unknown }) => unknown;
  @state() private _hasSlotNoEventsContent = false;
  @queryAssignedElements({ slot: 'noEventsContent', flatten: true }) private _slotNoEventsContentElements!: Element[];
  @property({ attribute: false }) noEventsContent?: (scope: { arg: unknown }) => 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();

    const portals = {
      event: (container: HTMLElement, scope: { arg: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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: unknown }): (() => 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);
        };
      },
    };

    this._disconnectCleanups.push((() => this.instance?.destroy()));

    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); }); }));

    const opts: Record<string, any> = {
      // :options passthrough spread FIRST — the curated keys below + the portal
      // *Content handlers added after this object override any colliding key, so
      // an explicitly-bound prop (e.g. :height) wins over options.height.
      //
      // 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.
      ...this.options,
      plugins: [...this.PLUGINS, ...(this.options?.plugins ?? [])],
      initialView: this.view,
      weekends: this.weekends,
      editable: this.editable,
      selectable: this.selectable,
      height: this.height,
      locale: this.locale,
      firstDay: this.firstDay,
      slotDuration: this.slotDuration,
      nowIndicator: this.nowIndicator,
      events: this.events.map(this.normalizeEvent),
      // 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("eventClick", {
          detail: {
            event: {
              id: info.event.id,
              title: info.event.title,
              start: info.event.start,
              end: info.event.end
            },
            jsEvent: info.jsEvent
          },
          bubbles: true,
          composed: true
        }));
      },
      dateClick: (info: any) => {
        this.dispatchEvent(new CustomEvent("dateClick", {
          detail: {
            date: info.date,
            dateStr: info.dateStr,
            allDay: info.allDay
          },
          bubbles: true,
          composed: true
        }));
      },
      eventDrop: (info: any) => {
        this.dispatchEvent(new CustomEvent("eventDrop", {
          detail: {
            event: {
              id: info.event.id,
              title: info.event.title,
              start: info.event.start,
              end: info.event.end
            },
            delta: info.delta
          },
          bubbles: true,
          composed: true
        }));
      },
      select: (info: any) => {
        this.dispatchEvent(new CustomEvent("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("eventResize", {
          detail: {
            event: {
              id: info.event.id,
              title: info.event.title,
              start: info.event.start,
              end: info.event.end
            },
            startDelta: info.startDelta,
            endDelta: info.endDelta
          },
          bubbles: true,
          composed: true
        }));
      },
      datesSet: (info: any) => {
        this.dispatchEvent(new CustomEvent("datesSet", {
          detail: {
            start: info.start,
            end: info.end,
            view: info.view.type
          },
          bubbles: true,
          composed: true
        }));
      },
      eventMouseEnter: (info: any) => {
        this.dispatchEvent(new CustomEvent("eventMouseEnter", {
          detail: {
            event: {
              id: info.event.id,
              title: info.event.title,
              start: info.event.start,
              end: info.event.end
            },
            jsEvent: info.jsEvent
          },
          bubbles: true,
          composed: true
        }));
      },
      eventMouseLeave: (info: any) => {
        this.dispatchEvent(new CustomEvent("eventMouseLeave", {
          detail: {
            event: {
              id: info.event.id,
              title: info.event.title,
              start: info.event.start,
              end: info.event.end
            },
            jsEvent: info.jsEvent
          },
          bubbles: true,
          composed: true
        }));
      },
      unselect: (info: any) => {
        this.dispatchEvent(new CustomEvent("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("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("eventsSet", {
          detail: {
            events: events.map((e: any) => ({
              id: e.id,
              title: e.title,
              start: e.start,
              end: e.end
            }))
          },
          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);
      }
    };

    // 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) {
      opts.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. (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. (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) {
      opts.dayCellContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayCell(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.dayHeader !== undefined) {
      opts.dayHeaderContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.dayHeader(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.slotLabel !== undefined) {
      opts.slotLabelContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.slotLabel(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.weekNumber !== undefined) {
      opts.weekNumberContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.weekNumber(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.nowIndicatorContent !== undefined) {
      opts.nowIndicatorContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.nowIndicatorContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.moreLink !== undefined) {
      opts.moreLinkContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.moreLink(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.allDayContent !== undefined) {
      opts.allDayContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.allDayContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    if (this.slotLaneContent !== undefined) {
      opts.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.
    // 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) {
      opts.noEventsContent = (arg: any) => {
        const node = document.createElement('div');
        const dispose = portals.noEventsContent(node, {
          arg
        });
        return {
          domNodes: [node],
          dispose
        };
      };
    }
    this.instance = new Calendar(this._ref__rozieRoot, opts);
    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.instance.removeAllEvents();
      for (const e of v as any) this.instance.addEvent(this.normalizeEvent(e));
    })(__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', 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) => 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) return;
      for (const k in v) 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;

  PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];

  normalizeEvent = (e: any) => {
  // Object spread + template-literal default — common reconcile shape:
  // pass user props through, but stamp a sensible title fallback and
  // honor the wrapper's defaultColor only when the event omits one.
  return {
    ...e,
    title: e.title || `Event ${e.id ?? '(no id)'}`,
    color: e.color || this.defaultColor
  };
};

  getApi() {
    return this.instance;
  }

  changeView(...a: any[]) {
    return this.instance?.changeView(...a);
  }

  addEvent(...a: any[]) {
    return this.instance?.addEvent(...a);
  }

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

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

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

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

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

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

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

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

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

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

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

  selectRange(...a: any[]) {
    this.instance?.select(...a);
  }

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

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

Each is a real, idiomatic 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, all from the one source above.

See also

  • FullCalendar — showcase & API — install, quick starts for all six frameworks, 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-v1.0 — internal monorepo.