Skip to content

FullCalendar — live demo

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

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

What ships for each framework

You author the component once as a .rozie file:

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

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

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

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

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

  Why this is a worthwhile demo:
    - NEW domain: calendar grids with Date objects (not strings)
    - NEW pattern: object spread inside an engine method call
      (`cal.addEvent({ ...info, id: nextId(), color: $props.defaultColor })`)
    - NEW pattern: template literals interpolating $data/$props in
      $script (event title formatting)
    - Multi-plugin import shape — @fullcalendar/core + per-view plugins
      via separate default imports
    - Structured-payload $emit: eventClick forwards { event, jsEvent,
      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 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 _onDateClickRef = useRef(props.onDateClick);
  _onDateClickRef.current = props.onDateClick;
  const _onDatesSetRef = useRef(props.onDatesSet);
  _onDatesSetRef.current = props.onDatesSet;
  const _onEventClickRef = useRef(props.onEventClick);
  _onEventClickRef.current = props.onEventClick;
  const _onEventDropRef = useRef(props.onEventDrop);
  _onEventDropRef.current = props.onEventDrop;
  const _onEventMouseEnterRef = useRef(props.onEventMouseEnter);
  _onEventMouseEnterRef.current = props.onEventMouseEnter;
  const _onEventMouseLeaveRef = useRef(props.onEventMouseLeave);
  _onEventMouseLeaveRef.current = props.onEventMouseLeave;
  const _onEventResizeRef = useRef(props.onEventResize);
  _onEventResizeRef.current = props.onEventResize;
  const _onEventsSetRef = useRef(props.onEventsSet);
  _onEventsSetRef.current = props.onEventsSet;
  const _onLoadingRef = useRef(props.onLoading);
  _onLoadingRef.current = props.onLoading;
  const _onSelectRef = useRef(props.onSelect);
  _onSelectRef.current = props.onSelect;
  const _onUnselectRef = useRef(props.onUnselect);
  _onUnselectRef.current = props.onUnselect;
  const _optionsRef = useRef(props.options);
  _optionsRef.current = props.options;
  const _selectableRef = useRef(props.selectable);
  _selectableRef.current = props.selectable;
  const _slotDurationRef = useRef(props.slotDuration);
  _slotDurationRef.current = props.slotDuration;
  const _weekendsRef = useRef(props.weekends);
  _weekendsRef.current = props.weekends;
  const _viewRef = useRef(view);
  _viewRef.current = view;
  const __rozieRoot = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);
  const _watch1First = useRef(true);
  const _watch2First = useRef(true);
  const _watch3First = useRef(true);
  const _watch4First = useRef(true);
  const _watch5First = useRef(true);
  const _watch6First = useRef(true);
  const _watch7First = useRef(true);
  const _watch8First = useRef(true);
  const _watch9First = useRef(true);
  const _watch10First = useRef(true);
  const _watch11First = useRef(true);

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

  const _normalizeEventRef = useRef(normalizeEvent);
  _normalizeEventRef.current = normalizeEvent;
  useEffect(() => {
    const _normalizeEventStable: typeof _normalizeEventRef.current = (...args) => _normalizeEventRef.current(...args);
    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(_normalizeEventStable),
      // D-02: a consumer-passed headerToolbar fully REPLACES the built-in
      // toolbar; the built-in default lives in the `headerToolbar` prop default.
      headerToolbar: _headerToolbarRef.current,
      eventClick: (info: any) => {
        _onEventClickRef.current && _onEventClickRef.current({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      dateClick: (info: any) => {
        _onDateClickRef.current && _onDateClickRef.current({
          date: info.date,
          dateStr: info.dateStr,
          allDay: info.allDay
        });
      },
      eventDrop: (info: any) => {
        _onEventDropRef.current && _onEventDropRef.current({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          delta: info.delta
        });
      },
      select: (info: any) => {
        _onSelectRef.current && _onSelectRef.current({
          start: info.start,
          end: info.end,
          startStr: info.startStr,
          endStr: info.endStr,
          allDay: info.allDay
        });
      },
      eventResize: (info: any) => {
        _onEventResizeRef.current && _onEventResizeRef.current({
          event: {
            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) => {
        _onDatesSetRef.current && _onDatesSetRef.current({
          start: info.start,
          end: info.end,
          view: info.view.type
        });
      },
      eventMouseEnter: (info: any) => {
        _onEventMouseEnterRef.current && _onEventMouseEnterRef.current({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      eventMouseLeave: (info: any) => {
        _onEventMouseLeaveRef.current && _onEventMouseLeaveRef.current({
          event: {
            id: info.event.id,
            title: info.event.title,
            start: info.event.start,
            end: info.event.end
          },
          jsEvent: info.jsEvent
        });
      },
      unselect: (info: any) => {
        _onUnselectRef.current && _onUnselectRef.current({
          jsEvent: info.jsEvent
        });
      },
      loading: (isLoading: any) => {
        // FullCalendar's `loading` callback receives a bare boolean (not an info
        // object) — normalize to the structured `{ isLoading }` payload shape.
        _onLoadingRef.current && _onLoadingRef.current({
          isLoading
        });
      },
      eventsSet: (events: any) => {
        // `eventsSet` receives the array of current EventApi objects — map each to
        // the normalized floor shape for persistence/sync consumers.
        _onEventsSetRef.current && _onEventsSetRef.current({
          events: events.map((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));
}, { flush: 'post' });
watch(() => view.value, (v: any) => {
  if (!instance || !v) return;
  if (v === instance.view.type) return;
  suppressViewSync = true;
  instance.changeView(v);
}, { flush: 'post' });
watch(() => props.weekends, (v: any) => instance?.setOption('weekends', v), { flush: 'post' });
watch(() => props.editable, (v: any) => instance?.setOption('editable', v), { flush: 'post' });
watch(() => props.selectable, (v: any) => instance?.setOption('selectable', v), { flush: 'post' });
watch(() => props.height, (v: any) => instance?.setOption('height', v), { flush: 'post' });
watch(() => props.locale, (v: any) => instance?.setOption('locale', v), { flush: 'post' });
watch(() => props.firstDay, (v: any) => instance?.setOption('firstDay', v), { flush: 'post' });
watch(() => props.slotDuration, (v: any) => instance?.setOption('slotDuration', v), { flush: 'post' });
watch(() => props.nowIndicator, (v: any) => instance?.setOption('nowIndicator', v), { flush: 'post' });
watch(() => props.headerToolbar, (v: any) => instance?.setOption('headerToolbar', v), { flush: 'post' });
watch(() => props.options, (v: any) => {
  if (!instance) return;
  for (const k in v) instance.setOption(k, v[k]);
}, { flush: 'post' });

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, computed, contentChild, contentChildren, effect, inject, input, model, output, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RozieSlot } from '@rozie/runtime-angular';

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

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: [`
    :host(rozie-full-calendar) { display: contents; }
    .rozie-fullcalendar {
      width: 100%;
      font-size: 0.875rem;
    }
  `],
})
export class FullCalendar {
  /**
   * The event objects rendered on the calendar. Each event is normalized: a missing `title` 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);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _eventTpl = contentChild('event', { read: TemplateRef });
  private _dayCellTpl = contentChild('dayCell', { read: TemplateRef });
  private _dayHeaderTpl = contentChild('dayHeader', { read: TemplateRef });
  private _slotLabelTpl = contentChild('slotLabel', { read: TemplateRef });
  private _weekNumberTpl = contentChild('weekNumber', { read: TemplateRef });
  private _nowIndicatorContentTpl = contentChild('nowIndicatorContent', { read: TemplateRef });
  private _moreLinkTpl = contentChild('moreLink', { read: TemplateRef });
  private _allDayContentTpl = contentChild('allDayContent', { read: TemplateRef });
  private _slotLaneContentTpl = contentChild('slotLaneContent', { read: TemplateRef });
  private _noEventsContentTpl = contentChild('noEventsContent', { read: TemplateRef });
  private __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.__rozieFillMap()['event'] ?? 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.__rozieFillMap()['dayCell'] ?? 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.__rozieFillMap()['dayHeader'] ?? 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.__rozieFillMap()['slotLabel'] ?? 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.__rozieFillMap()['weekNumber'] ?? 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.__rozieFillMap()['nowIndicatorContent'] ?? 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.__rozieFillMap()['moreLink'] ?? 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.__rozieFillMap()['allDayContent'] ?? 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.__rozieFillMap()['slotLaneContent'] ?? 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.__rozieFillMap()['noEventsContent'] ?? 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: (() => [])() as any[], 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'
}))() as Record<string, any>, options: (() => ({}))() as Record<string, any> }, _props);
  const [local, attrs] = splitProps(_merged, ['events', 'view', 'weekends', 'editable', 'selectable', 'height', 'defaultColor', 'locale', 'firstDay', 'slotDuration', 'nowIndicator', 'headerToolbar', 'options', 'ref', 'onEventClick', 'onDateClick', 'onEventDrop', 'onSelect', 'onEventResize', 'onDatesSet', 'onEventMouseEnter', 'onEventMouseLeave', 'onUnselect', 'onLoading', 'onEventsSet']);
  onMount(() => { local.ref?.({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection }); });

  const [view, setView] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'view', 'dayGridMonth');
  const portalDisposers = new Set<() => void>();
  const portals = {
    event: (container: HTMLElement, scope: { arg: 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: any;
}

interface RozieDayCellSlotCtx {
  arg: any;
}

interface RozieDayHeaderSlotCtx {
  arg: any;
}

interface RozieSlotLabelSlotCtx {
  arg: any;
}

interface RozieWeekNumberSlotCtx {
  arg: any;
}

interface RozieNowIndicatorContentSlotCtx {
  arg: any;
}

interface RozieMoreLinkSlotCtx {
  arg: any;
}

interface RozieAllDayContentSlotCtx {
  arg: any;
}

interface RozieSlotLaneContentSlotCtx {
  arg: any;
}

interface RozieNoEventsContentSlotCtx {
  arg: any;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  firstUpdated(): void {
    adoptDocumentStyles(this);

    this._armListeners();

    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("event-click", {
          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("date-click", {
          detail: {
            date: info.date,
            dateStr: info.dateStr,
            allDay: info.allDay
          },
          bubbles: true,
          composed: true
        }));
      },
      eventDrop: (info: any) => {
        this.dispatchEvent(new CustomEvent("event-drop", {
          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("event-resize", {
          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("dates-set", {
          detail: {
            start: info.start,
            end: info.end,
            view: info.view.type
          },
          bubbles: true,
          composed: true
        }));
      },
      eventMouseEnter: (info: any) => {
        this.dispatchEvent(new CustomEvent("event-mouse-enter", {
          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("event-mouse-leave", {
          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("events-set", {
          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 component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, identical on every target.

See also

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

Pre-1.0 — APIs may change between minor versions.