Skip to content

Embla — live demo

This is the real @rozie-ui/embla-vue package running on this page (VitePress is itself a Vue app). Drag the carousel, use the prev/next buttons, jump to a dot — then watch the bound index update. The same Carousel component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.

The snap index is two-way bound with v-model:selectedIndex — the readout updates live as you drag or scroll, and the buttons drive the imperative handle (scrollPrev, scrollNext, scrollToIndex). Dragging the carousel writes the new index back through the model; clicking a dot writes the index in and scrolls the carousel — round-trip, echo-guarded. 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
<!--
  Carousel.rozie — cross-framework port of Embla Carousel (v8).

  One source → idiomatic React / Vue / Svelte / Angular / Solid / Lit carousels,
  each a working Embla v8 instance. Fills a genuine cross-framework gap: Lit has
  no Embla wrapper at all, Angular's is a single-maintainer community package, and
  the four official wrappers are four divergent APIs (a hook vs a composable vs an
  action vs a Solid primitive).

  This is the SortableList pattern, SIMPLIFIED. Like SortableList we own the host,
  render consumer children inside, attach the vanilla engine to a $refs in
  $onMount, $watch→reconcile, two-way bind, and $expose the imperative surface.
  Unlike SortableList, Embla only READS + transforms slide DOM (it sets
  `transform: translate3d(...)` on the container it owns) — it never reorders the
  framework's children. So NO r-external, NO $reconcileAfterDomMutation, NO
  DOM-restore helper, NO $snapshot, NO colocated bridge.

  Two slide-source modes from one component, working identically on all six targets
  (quick 260807-cor, D4 — declarative mode used to be Lit-blind; see the
  `$slotted.default` sigil usage below):
    (a) config array:   <Carousel :slides="items" r-model:selectedIndex="idx" />
    (b) declarative:    <Carousel><div class="rozie-embla__slide">…</div></Carousel>
  Embla's native `watchSlides: true` self-reInits on default-slot child add/remove,
  but its MutationObserver watches the SHADOW container on Lit — it never fires for
  a light-DOM mutation there. Two explicit $watch backstops cover the gap: one on
  $props.slides.length for config-array mode, one on $slotted.default.length for
  declarative mode (the latter is Lit-load-bearing, inert elsewhere).

  Consumer example (Vue):

    <Carousel
      :slides="['A', 'B', 'C']"
      v-model:selectedIndex="idx"
      :loop="true"
      @select="(i) => console.log('snap', i)"
    />
    <button @click="carousel.scrollNext()">Next</button>
-->

<rozie name="Carousel">

<props>
{
  // Slide data for config-array mode (mode a). Optional — the default <slot/> is
  // mode b (consumer drops slide DOM directly).
  slides:         {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.',
      example: "<Carousel :slides=\"['A', 'B', 'C']\" r-model:selectedIndex=\"idx\" />",
    },
  },
  // Runtime-updatable Embla options — each is $watch'd → embla.reInit() (Embla has
  // no per-option setter; reInit is the only update path).
  loop:           {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.',
    },
  },
  align:          {
    type: String,
    default: 'center',
    docs: {
      description:
        "Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.",
    },
  },
  axis:           {
    type: String,
    default: 'x',
    docs: {
      description:
        "Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.",
    },
  },
  slidesToScroll: {
    type: Number,
    default: 1,
    docs: {
      description:
        'Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.',
    },
  },
  dragFree:       {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.',
    },
  },
  // `draggable` → Embla `watchDrag` (Vue-clarity rename; false disables pointer drag).
  draggable:      {
    type: Boolean,
    default: true,
    docs: {
      description:
        'Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.',
    },
  },
  containScroll:  {
    type: String,
    default: 'trimSnaps',
    docs: {
      description:
        "Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.",
    },
  },
  startIndex:     {
    type: Number,
    default: 0,
    docs: {
      description:
        'Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.',
    },
  },
  skipSnaps:      {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.',
    },
  },
  duration:       {
    type: Number,
    default: 25,
    docs: {
      description:
        "Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.",
    },
  },
  direction:      {
    type: String,
    default: 'ltr',
    docs: {
      description:
        "Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.",
    },
  },
  // Autoplay plugin toggle + delay.
  autoplay:       {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.',
    },
  },
  autoplayDelay:  {
    type: Number,
    default: 4000,
    docs: {
      description:
        'Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.',
    },
  },
  // ─── built-in navigation UI (all opt-in, default off) ───────────────────────
  // dot pagination (one dot per scroll snap; the selected snap is highlighted;
  // clicking a dot scrolls to it).
  dots:           {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.',
    },
  },
  // prev/next arrow buttons overlaid on the viewport (disabled at the ends unless
  // `loop`).
  arrows:         {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.',
    },
  },
  // a synced thumbnail strip below the carousel — its OWN Embla instance, one
  // thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom
  // thumb content (falls back to the slide value). Clicking a thumb scrolls the
  // main carousel; the main selection highlights + scrolls the active thumb.
  thumbnails:     {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.',
    },
  },
  // Escape hatches: extra Embla plugins appended verbatim; raw EmblaOptionsType
  // spread last (the SortableList ...$props.options precedent).
  plugins:        {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.',
    },
  },
  options:        {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        'Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.',
    },
  },
  // Two-way: the current scroll snap. DISTINCT from the `select` emit (the
  // MapLibre model⇄emit rule — a model prop must NOT share a name with an emit or
  // the continuous emit drops on Vue/Angular). Read $props.selectedIndex, write
  // $model.selectedIndex.
  selectedIndex:  {
    type: Number,
    default: 0,
    model: true,
    docs: {
      description:
        'The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.',
      example: '<Carousel :slides="items" r-model:selectedIndex="idx" />',
    },
  },
}
</props>

<data>
{
  // reactive nav state — driven from the engine on select/reInit by syncNav() so
  // the built-in dots/arrows render reactively across all six targets.
  //   snaps:    one entry per scroll snap (an index array → one dot per snap).
  //   selected: the current snap index (highlights the active dot/thumb).
  //   canPrev / canNext: arrow enabled state (false at the ends unless `loop`).
  snaps: [],
  selected: 0,
  canPrev: false,
  canNext: false,
}
</data>

<script>
import EmblaCarousel from 'embla-carousel'
import Autoplay from 'embla-carousel-autoplay'

// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla = null
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs = null
// D7: the two mount-time remeasure rAF handles + the macrotask handle, captured
// so the mount cleanup can cancel them on unmount. Null-lets (not `= 0`) so they
// type-neutralize to `any` — setTimeout's return type differs between the DOM
// and Node lib shapes across the six leaves' tsconfigs.
let remeasureRafOuter = null
let remeasureRafInner = null
let remeasureTimer = null

// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide, i) => {
  if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i
  return slide ?? i
}

// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const initialOptions = () => {
  let opts = null
  opts = {
    // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
    // branch, an explicit element list only on the TRUE branch. This is
    // deliberately NOT an unconditional array — Embla's own `storeElements()`
    // re-resolves a STRING selector fresh via `container.querySelectorAll()`
    // on EVERY `reInit()` call, including its OWN internally-triggered ones
    // (the native `watchSlides: true` MutationObserver calls `reInit()` with
    // NO arguments, which reuses whatever `slides` value was last explicitly
    // set — a materialized ARRAY would freeze at that point, silently
    // breaking watchSlides' self-healing for ANY post-mount slide-count
    // change on ALL SIX targets, not just Lit; verified live via the VR
    // union matrix, not just reasoned about). Keeping the selector string as
    // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
    // behavior — for config-array mode on every target, AND for declarative
    // mode on the five hostless targets, AND for Lit's config-array mode
    // (all of those cases have real shadow/light-DOM descendants the
    // selector correctly re-finds every time, exactly as before this task).
    // `$slotted.default.length` is a compile-time-constant `0` on the five
    // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
    // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
    // always-false runtime check, byte-behavior-identical to the pre-D4
    // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
    // there IS declarative (light-DOM) content assigned to the default slot,
    // does it switch to the explicit list — the phantom-slot problem
    // (`container.children` fallback catching the trailing empty <slot>) is
    // avoided the same way it always was: the selector filters to
    // `.rozie-embla__slide` elements only. `...$props.options` still
    // overrides `slides` last if a consumer needs to.
    slides:
      $slotted.default.length > 0
        ? [...$refs.viewportEl.querySelectorAll('.rozie-embla__slide'), ...$slotted.default]
        : '.rozie-embla__slide',
    loop:           $props.loop,
    align:          $props.align,
    axis:           $props.axis,
    slidesToScroll: $props.slidesToScroll,
    dragFree:       $props.dragFree,
    watchDrag:      $props.draggable,
    containScroll:  $props.containScroll,
    startIndex:     $props.startIndex,
    skipSnaps:      $props.skipSnaps,
    duration:       $props.duration,
    direction:      $props.direction,
    ...$props.options,
  }
  return opts
}

// startIndex is INIT-ONLY. Embla's reActivate preserves the live position by
// merging mergeOptions({ startIndex: selectedScrollSnap() }, withOptions) — and
// withOptions WINS (embla-carousel@8 esm :1450, :1558). So any startIndex left
// in a reInit payload teleports the carousel back to the prop's value on every
// option flip, slide add/remove, and no-arg reInitCarousel(). Delete it AFTER
// the ...$props.options spread so the raw escape hatch cannot reintroduce it
// either. To move programmatically, use the scrollToIndex() handle verb.
const reinitOptions = () => {
  let opts = null
  opts = initialOptions()
  delete opts.startIndex
  return opts
}

// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
  const builtins = $props.autoplay ? [Autoplay({ delay: $props.autoplayDelay })] : []
  return [...builtins, ...$props.plugins]
}

// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as initialOptions (axis is a `string`).
const thumbsOptionsFromProps = () => {
  let opts = null
  opts = { containScroll: 'keepSnaps', dragFree: true, axis: $props.axis }
  return opts
}

// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
  if (!embla) return
  const i = embla.selectedScrollSnap()
  $data.snaps = embla.scrollSnapList().map((_, n) => n)
  $data.selected = i
  $data.canPrev = embla.canScrollPrev()
  $data.canNext = embla.canScrollNext()
  if (emblaThumbs) emblaThumbs.scrollTo(i)
}

// Thumb click → scroll the MAIN carousel. Calls the $expose'd scrollToIndex verb
// directly (below) — arg-light internal calls to an exposed verb now typecheck
// cleanly on all six targets: the emitter lowers a TRAILING $expose verb param
// optional (`jump?: any` / `index`+`jump?`) whenever it sees a fewer-arg internal
// call site (emitter-hardening backlog item #5). The prior raw-engine
// navPrev/navNext/navTo bypass existed ONLY to dodge the pre-fix required-arg
// TS2554 and is gone now that the compiler owns the arity.
//
// NB: no `clickAllowed()` drag-vs-click guard. Embla 8 dropped `clickAllowed`
// from the public API entirely (it isn't a method on EmblaCarouselType), so the
// old guard threw `TypeError: emblaThumbs.clickAllowed is not a function` on
// every thumb tap. The modern Embla thumbs idiom calls `scrollTo` directly; a
// drag that ends on a thumb simply scrolls, which is acceptable for a nav strip.
const selectThumb = (i) => {
  scrollToIndex(i)
}

$onMount(() => {
  embla = EmblaCarousel($refs.viewportEl, initialOptions(), emblaPluginsFromProps())

  // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
  // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
  // only $refs-safe site). Stays null otherwise (zero overhead).
  if ($props.thumbnails && $refs.thumbsViewportEl) {
    emblaThumbs = EmblaCarousel($refs.thumbsViewportEl, thumbsOptionsFromProps())
  }

  // engine → consumer: on every snap change write the two-way model AND fire the
  // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
  // refreshes the built-in dots/arrows + thumb sync.
  embla.on('select', () => {
    const i = embla.selectedScrollSnap()
    $model.selectedIndex = i
    $emit('select', i)
    syncNav()
  })
  embla.on('settle', () => $emit('settle'))
  embla.on('reInit', () => { $emit('reInit'); syncNav() })
  embla.on('pointerDown', () => $emit('pointer-down'))
  // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
  // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
  // at init is stale — and a slide-size change (vs a viewport resize or slide
  // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
  // its own. Re-measure once after the first layout flush via reInit (its `reInit`
  // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
  embla.on('resize', () => syncNav())

  // seed the nav state immediately (covers the already-laid-out case)…
  syncNav()
  // …then re-measure after layout fully settles (a consumer's slide CSS / a root
  // width via attribute fallthrough can land a couple of frames after $onMount;
  // Embla caches slide sizes at init and a slide-size change alone fires no
  // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
  // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
  if (typeof requestAnimationFrame === 'function') {
    const remeasure = () => { if (embla) embla.reInit(reinitOptions(), emblaPluginsFromProps()) }
    remeasureRafOuter = requestAnimationFrame(() => {
      remeasureRafInner = requestAnimationFrame(remeasure)
    })
    remeasureTimer = setTimeout(remeasure, 0)
  }

  // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
  // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
  // fall through their existing `if (embla)` / ternary guards after unmount, so
  // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
  // symmetric — null before mount AND after unmount — instead of calling into a
  // destroyed engine.
  return () => {
    if (remeasureRafOuter) cancelAnimationFrame(remeasureRafOuter)
    if (remeasureRafInner) cancelAnimationFrame(remeasureRafInner)
    if (remeasureTimer) clearTimeout(remeasureTimer)
    remeasureRafOuter = null
    remeasureRafInner = null
    remeasureTimer = null
    if (embla) { embla.destroy(); embla = null }
    if (emblaThumbs) { emblaThumbs.destroy(); emblaThumbs = null }
  }
})

// consumer → engine (echo-guarded, the MapLibre camera precedent): when the bound
// index changes and differs from the engine's current snap, scroll to it. The
// guard (i !== selectedScrollSnap()) makes the engine→model echo a no-op, so no
// feedback loop.
$watch(() => $props.selectedIndex, (i) => {
  if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i)
})

// Reactive options → reInit. Embla has no per-option setter; reInit(opts) is the
// only update path. The getter returns a stable primitive SIGNATURE string so the
// watch fires once per actual change (a fresh options object would fire every tick
// on reference inequality — playbook §4).
$watch(
  () => [
    $props.loop, $props.align, $props.axis, $props.slidesToScroll, $props.dragFree,
    $props.draggable, $props.containScroll, $props.skipSnaps, $props.duration,
    $props.direction,
  ].join('|'),
  () => embla?.reInit(reinitOptions()),
)

// Toggling autoplay or autoplayDelay rebuilds the plugin set — reInit takes a
// second `plugins` arg. NOTE: `plugins` and `options` are INIT-ONLY — neither is
// in this watch's getter, so a runtime change to the raw plugin array or the
// `options` escape hatch is not observed (see docs/components/embla.md for the
// rationale against watching either).
$watch(
  () => `${$props.autoplay}|${$props.autoplayDelay}`,
  () => embla?.reInit(reinitOptions(), emblaPluginsFromProps()),
)

// Config-array slide-count backstop: Embla's native `watchSlides` self-reInits on
// default-slot child add/remove, but the config-array (r-for) path needs an
// explicit length watch so a data-driven slide add/remove recomputes snaps. The
// thumbnail strip iterates the same slides, so reInit it too, then resync nav.
$watch(() => $props.slides.length, () => {
  embla?.reInit(reinitOptions())
  emblaThumbs?.reInit(thumbsOptionsFromProps())
  syncNav()
})

// quick 260807-cor (D4) — declarative-mode (mode b) reInit backstop, the
// Lit-load-bearing mirror of the config-array backstop above. Embla's own
// native `watchSlides: true` attaches a MutationObserver to the SHADOW
// container it was constructed with — it never fires for a light-DOM
// mutation on Lit (the custom element boundary the MutationObserver cannot
// see across). $slotted.default.length is the getter that DOES see across
// that boundary. On the five hostless targets this getter is a compile-time
// constant (Task 1 drops the `slotted` scope from every React dep array, so
// there's no churn concern there either), making this watch inert there —
// Embla's own native watchSlides observer already covers default-slot
// mutations on those targets, since slot content is a real descendant of
// the container it observes.
$watch(() => $slotted.default.length, () => {
  embla?.reInit(reinitOptions())
  syncNav()
})

// Runtime toggle of `thumbnails`: build/tear down the strip's Embla instance so the
// feature can be switched on/off after mount. $refs is safe to read in a $watch
// CALLBACK (post-mount; only the GETTER is ROZ123-restricted). The r-if mounts/
// unmounts the viewport element in the same tick. Teardown stays synchronous
// (destroying needs no DOM); only the BUILD defers.
$watch(() => $props.thumbnails, (on) => {
  if (!on) {
    if (emblaThumbs) { emblaThumbs.destroy(); emblaThumbs = null }
    return
  }
  if (emblaThumbs) return
  // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
  // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
  // ViewportEl is still null when the callback runs, so a synchronous build
  // silently no-ops and a runtime thumbnails toggle never gets an engine.
  // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
  // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
  // lands first after the flush wins; targets that already flushed
  // synchronously take the immediate fast path in `build()` below and never
  // wait on the deferred passes.
  const build = () => {
    if (!embla) return                 // unmounted — the $onMount cleanup nulled it
    if (!$props.thumbnails) return     // toggled back off before this pass ran
    if (emblaThumbs) return            // idempotent across the double-schedule
    if (!$refs.thumbsViewportEl) return
    emblaThumbs = EmblaCarousel($refs.thumbsViewportEl, thumbsOptionsFromProps())
    syncNav()
  }
  build()
  if (typeof queueMicrotask !== 'undefined') queueMicrotask(build)
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build)
})

// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
//  - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
//    260802-tmo D1: a DELIBERATE behavior change to this published verb —
//    no-arg reInitCarousel() now PRESERVES the current snap (via
//    reinitOptions()) instead of resetting to `startIndex` on every call. Pass
//    raw options to override. See docs/components/embla.md's handle table.
//  - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
//    setter collision on Lit/Angular; it's a method, the prop is the two-way value).
//  - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
//    bare `scrollTo` expose verb becomes a public method on the Lit custom-element
//    class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
//    `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
//    resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
//    method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
//  - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
//    React `setPlugins` auto-setter) — the get* getter convention. Returns the
//    live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
//  - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
//    progress bars, lazy-load/in-view dots, and directional transitions — no
//    matching prop, emit, or inherited DOM method — clear.
//  - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump)            { if (embla) embla.scrollNext(jump) }
function scrollPrev(jump)            { if (embla) embla.scrollPrev(jump) }
function scrollToIndex(index, jump)  { if (embla) embla.scrollTo(index, jump) }
function reInitCarousel(opts)        { if (embla) embla.reInit(opts ?? reinitOptions(), emblaPluginsFromProps()) }
function canScrollNext()             { return embla ? embla.canScrollNext() : false }
function canScrollPrev()             { return embla ? embla.canScrollPrev() : false }
function getSelectedIndex()          { return embla ? embla.selectedScrollSnap() : 0 }
function scrollSnapList()            { return embla ? embla.scrollSnapList() : [] }
function scrollProgress()            { return embla ? embla.scrollProgress() : 0 }
function slidesInView()              { return embla ? embla.slidesInView() : [] }
function slidesNotInView()           { return embla ? embla.slidesNotInView() : [] }
function previousScrollSnap()        { return embla ? embla.previousScrollSnap() : 0 }
function getPlugins()                { return embla ? embla.plugins() : null }
function getInstance()               { return embla }

$expose({
  scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev,
  getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView,
  previousScrollSnap, getPlugins, getInstance,
})
</script>

<template>
<div class="rozie-embla" :class="{ 'rozie-embla--vertical': $props.axis === 'y' }">
  <!-- stage = the viewport + the overlaid prev/next arrows (position context so
       the absolute arrows align to the VIEWPORT, not the dots/thumbs below). -->
  <div class="rozie-embla__stage">
    <button
      type="button"
      class="rozie-embla__arrow rozie-embla__arrow--prev"
      r-if="$props.arrows"
      :disabled="!$data.canPrev"
      @click="scrollPrev()"
      aria-label="Previous slide"
    >‹</button>
    <div class="rozie-embla__viewport" ref="viewportEl">
      <div class="rozie-embla__container">
        <!-- (a) config-array mode: render slides from $props.slides. A `slide`-named
             r-for variable == the `slide` SLOT is now AUTO-FIXED by the Svelte
             emitter (Phase 73 item #1): `findRForSlotNameCollisions` detects the
             shadow and renames the lowered snippet binding to `slide$$slot`
             (author's loop var + slot-arg values untouched) — no more hand-workaround
             needed to keep the iteration variable distinct from the slot name. -->
        <div
          r-for="slide, i in $props.slides"
          :key="keyFor(slide, i)"
          class="rozie-embla__slide"
        >
          <slot name="slide" :slide="slide" :index="i">{{ slide }}</slot>
        </div>
        <!-- (b) declarative mode: consumer drops slide DOM directly -->
        <slot />
      </div>
    </div>
    <button
      type="button"
      class="rozie-embla__arrow rozie-embla__arrow--next"
      r-if="$props.arrows"
      :disabled="!$data.canNext"
      @click="scrollNext()"
      aria-label="Next slide"
    >›</button>
  </div>

  <!-- dot pagination — one button per scroll snap; the selected snap is active. -->
  <div class="rozie-embla__dots" r-if="$props.dots">
    <button
      r-for="di in $data.snaps"
      :key="di"
      type="button"
      class="rozie-embla__dot"
      :class="{ 'is-selected': di === $data.selected }"
      @click="scrollToIndex(di)"
      :aria-label="'Go to slide ' + (di + 1)"
    />
  </div>

  <!-- synced thumbnail strip — its own Embla instance (thumbsViewportEl), one thumb
       per slide. Fill the `thumb` scoped slot for custom content (falls back to the
       slide value). Clicking a thumb scrolls the main carousel. -->
  <div class="rozie-embla__thumbs" r-if="$props.thumbnails">
    <div class="rozie-embla__thumbs-viewport" ref="thumbsViewportEl">
      <div class="rozie-embla__thumbs-container">
        <div
          r-for="item, i in $props.slides"
          :key="keyFor(item, i)"
          class="rozie-embla__thumb"
          :class="{ 'is-selected': i === $data.selected }"
          @click="selectThumb(i)"
        >
          <slot name="thumb" :slide="item" :index="i">{{ item }}</slot>
        </div>
      </div>
    </div>
  </div>
</div>
</template>

<style>
.rozie-embla { position: relative; }
.rozie-embla__stage { position: relative; }
.rozie-embla__viewport { overflow: hidden; }
.rozie-embla__container { display: flex; }
.rozie-embla__slide { flex: 0 0 100%; min-width: 0; }

/* Vertical axis: stack slides in a column inside a height-constrained viewport. */
.rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
.rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }

/* ─── prev/next arrows (opt-in via `arrows`) ──────────────────────────────────
   Overlaid on the viewport, vertically centered on the STAGE (so the dots/thumbs
   below don't shift them). Every visual value below is a `--rozie-embla-*`
   custom property with an inline fallback, so the default look ships with zero
   config — override any token at any ancestor scope, or bridge to a design
   system via src/themes/{shadcn,material,bootstrap}.css. */
.rozie-embla__arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-embla-arrow-size, 2.25rem);
  height: var(--rozie-embla-arrow-size, 2.25rem);
  padding: 0;
  border: none;
  border-radius: var(--rozie-embla-arrow-radius, 50%);
  background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
  color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
  font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
  transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
.rozie-embla__arrow:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
.rozie-embla__arrow--prev { left: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__arrow--next { right: var(--rozie-embla-arrow-inset, 0.5rem); }

/* ─── dot pagination (opt-in via `dots`) ──────────────────────────────────────── */
.rozie-embla__dots {
  display: flex;
  justify-content: center;
  gap: var(--rozie-embla-dots-gap, 0.4rem);
  padding: var(--rozie-embla-dots-padding, 0.625rem 0);
}
.rozie-embla__dot {
  width: var(--rozie-embla-dot-size, 0.5rem);
  height: var(--rozie-embla-dot-size, 0.5rem);
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
.rozie-embla__dot.is-selected {
  background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
  transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
}

/* ─── synced thumbnail strip (opt-in via `thumbnails`) ────────────────────────── */
.rozie-embla__thumbs { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
.rozie-embla__thumbs-viewport { overflow: hidden; }
.rozie-embla__thumbs-container { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
.rozie-embla__thumb {
  flex: 0 0 auto;
  cursor: pointer;
  opacity: var(--rozie-embla-thumb-opacity, 0.5);
  border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
  border-radius: var(--rozie-embla-thumb-radius, 4px);
  overflow: hidden;
  transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
.rozie-embla__thumb.is-selected {
  opacity: var(--rozie-embla-thumb-selected-opacity, 1);
  border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
}
</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/embla-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './Carousel.css';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';

// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.

interface SlideCtx { slide: any; index: any; }

interface ThumbCtx { slide: any; index: any; }

interface CarouselProps {
  /**
   * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
   * @example
   * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
   */
  slides?: any[];
  /**
   * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
   */
  loop?: boolean;
  /**
   * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
   */
  align?: string;
  /**
   * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
   */
  axis?: string;
  /**
   * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
   */
  slidesToScroll?: number;
  /**
   * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
   */
  dragFree?: boolean;
  /**
   * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
   */
  draggable?: boolean;
  /**
   * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
   */
  containScroll?: string;
  /**
   * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
   */
  startIndex?: number;
  /**
   * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
   */
  skipSnaps?: boolean;
  /**
   * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
   */
  duration?: number;
  /**
   * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
   */
  direction?: string;
  /**
   * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
   */
  autoplay?: boolean;
  /**
   * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
   */
  autoplayDelay?: number;
  /**
   * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
   */
  dots?: boolean;
  /**
   * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
   */
  arrows?: boolean;
  /**
   * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
   */
  thumbnails?: boolean;
  /**
   * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
   */
  plugins?: any[];
  /**
   * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
   */
  options?: Record<string, any>;
  /**
   * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
   * @example
   * <Carousel :slides="items" r-model:selectedIndex="idx" />
   */
  selectedIndex?: number;
  defaultSelectedIndex?: number;
  onSelectedIndexChange?: (selectedIndex: number) => void;
  onSelect?: (...args: any[]) => void;
  onSettle?: (...args: any[]) => void;
  onReInit?: (...args: any[]) => void;
  onPointerDown?: (...args: any[]) => void;
  renderSlide?: (ctx: SlideCtx) => ReactNode;
  children?: ReactNode;
  renderThumb?: (ctx: ThumbCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface CarouselHandle {
  scrollNext: (...args: any[]) => any;
  scrollPrev: (...args: any[]) => any;
  scrollToIndex: (...args: any[]) => any;
  reInitCarousel: (...args: any[]) => any;
  canScrollNext: (...args: any[]) => any;
  canScrollPrev: (...args: any[]) => any;
  getSelectedIndex: (...args: any[]) => any;
  scrollSnapList: (...args: any[]) => any;
  scrollProgress: (...args: any[]) => any;
  slidesInView: (...args: any[]) => any;
  slidesNotInView: (...args: any[]) => any;
  previousScrollSnap: (...args: any[]) => any;
  getPlugins: (...args: any[]) => any;
  getInstance: (...args: any[]) => any;
}

const Carousel = forwardRef<CarouselHandle, CarouselProps>(function Carousel(_props: CarouselProps, ref): JSX.Element {
  const __defaultSlides = useState(() => (() => [])())[0];
  const __defaultPlugins = useState(() => (() => [])())[0];
  const __defaultOptions = useState(() => (() => ({}))())[0];
  const props: Omit<CarouselProps, 'slides' | 'loop' | 'align' | 'axis' | 'slidesToScroll' | 'dragFree' | 'draggable' | 'containScroll' | 'startIndex' | 'skipSnaps' | 'duration' | 'direction' | 'autoplay' | 'autoplayDelay' | 'dots' | 'arrows' | 'thumbnails' | 'plugins' | 'options'> & { slides: any[]; loop: boolean; align: string; axis: string; slidesToScroll: number; dragFree: boolean; draggable: boolean; containScroll: string; startIndex: number; skipSnaps: boolean; duration: number; direction: string; autoplay: boolean; autoplayDelay: number; dots: boolean; arrows: boolean; thumbnails: boolean; plugins: any[]; options: Record<string, any> } = {
    ..._props,
    slides: _props.slides ?? __defaultSlides,
    loop: _props.loop ?? false,
    align: _props.align ?? 'center',
    axis: _props.axis ?? 'x',
    slidesToScroll: _props.slidesToScroll ?? 1,
    dragFree: _props.dragFree ?? false,
    draggable: _props.draggable ?? true,
    containScroll: _props.containScroll ?? 'trimSnaps',
    startIndex: _props.startIndex ?? 0,
    skipSnaps: _props.skipSnaps ?? false,
    duration: _props.duration ?? 25,
    direction: _props.direction ?? 'ltr',
    autoplay: _props.autoplay ?? false,
    autoplayDelay: _props.autoplayDelay ?? 4000,
    dots: _props.dots ?? false,
    arrows: _props.arrows ?? false,
    thumbnails: _props.thumbnails ?? false,
    plugins: _props.plugins ?? __defaultPlugins,
    options: _props.options ?? __defaultOptions,
  };
  const attrs: Record<string, unknown> = (() => {
    const { slides, loop, align, axis, slidesToScroll, dragFree, draggable, containScroll, startIndex, skipSnaps, duration, direction, autoplay, autoplayDelay, dots, arrows, thumbnails, plugins, options, selectedIndex, defaultValue, onSelectedIndexChange, defaultSelectedIndex, onSelect, onSettle, onReInit, onPointerDown, ...rest } = _props as CarouselProps & Record<string, unknown>;
    void slides; void loop; void align; void axis; void slidesToScroll; void dragFree; void draggable; void containScroll; void startIndex; void skipSnaps; void duration; void direction; void autoplay; void autoplayDelay; void dots; void arrows; void thumbnails; void plugins; void options; void selectedIndex; void defaultValue; void onSelectedIndexChange; void defaultSelectedIndex; void onSelect; void onSettle; void onReInit; void onPointerDown;
    return rest;
  })();
  const embla = useRef<any>(null);
  const emblaThumbs = useRef<any>(null);
  const remeasureRafOuter = useRef<any>(null);
  const remeasureRafInner = useRef<any>(null);
  const remeasureTimer = useRef<any>(null);
  const [selectedIndex, setSelectedIndex] = useControllableState({
    value: props.selectedIndex,
    defaultValue: props.defaultSelectedIndex ?? 0,
    onValueChange: props.onSelectedIndexChange,
  });
  const _onPointerDownRef = useRef(props.onPointerDown);
  _onPointerDownRef.current = props.onPointerDown;
  const _onReInitRef = useRef(props.onReInit);
  _onReInitRef.current = props.onReInit;
  const _onSelectRef = useRef(props.onSelect);
  _onSelectRef.current = props.onSelect;
  const _onSettleRef = useRef(props.onSettle);
  _onSettleRef.current = props.onSettle;
  const _thumbnailsRef = useRef(props.thumbnails);
  _thumbnailsRef.current = props.thumbnails;
  const [snaps, setSnaps] = useState<any[]>([]);
  const [selected, setSelected] = useState(0);
  const [canPrev, setCanPrev] = useState(false);
  const [canNext, setCanNext] = useState(false);
  const viewportEl = useRef<HTMLDivElement | null>(null);
  const thumbsViewportEl = 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);

  function keyFor(slide: any, i: any) {
    if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
    return slide ?? i;
  }
  const initialOptions = useCallback(() => {
    let opts: any = null;
    opts = {
      // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
      // branch, an explicit element list only on the TRUE branch. This is
      // deliberately NOT an unconditional array — Embla's own `storeElements()`
      // re-resolves a STRING selector fresh via `container.querySelectorAll()`
      // on EVERY `reInit()` call, including its OWN internally-triggered ones
      // (the native `watchSlides: true` MutationObserver calls `reInit()` with
      // NO arguments, which reuses whatever `slides` value was last explicitly
      // set — a materialized ARRAY would freeze at that point, silently
      // breaking watchSlides' self-healing for ANY post-mount slide-count
      // change on ALL SIX targets, not just Lit; verified live via the VR
      // union matrix, not just reasoned about). Keeping the selector string as
      // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
      // behavior — for config-array mode on every target, AND for declarative
      // mode on the five hostless targets, AND for Lit's config-array mode
      // (all of those cases have real shadow/light-DOM descendants the
      // selector correctly re-finds every time, exactly as before this task).
      // `$slotted.default.length` is a compile-time-constant `0` on the five
      // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
      // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
      // always-false runtime check, byte-behavior-identical to the pre-D4
      // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
      // there IS declarative (light-DOM) content assigned to the default slot,
      // does it switch to the explicit list — the phantom-slot problem
      // (`container.children` fallback catching the trailing empty <slot>) is
      // avoided the same way it always was: the selector filters to
      // `.rozie-embla__slide` elements only. `...$props.options` still
      // overrides `slides` last if a consumer needs to.
      slides: [].length > 0 ? [...viewportEl.current!.querySelectorAll('.rozie-embla__slide'), ...[]] : '.rozie-embla__slide',
      loop: props.loop,
      align: props.align,
      axis: props.axis,
      slidesToScroll: props.slidesToScroll,
      dragFree: props.dragFree,
      watchDrag: props.draggable,
      containScroll: props.containScroll,
      startIndex: props.startIndex,
      skipSnaps: props.skipSnaps,
      duration: props.duration,
      direction: props.direction,
      ...props.options
    };
    return opts;
  }, [props.align, props.axis, props.containScroll, props.direction, props.dragFree, props.draggable, props.duration, props.loop, props.options, props.skipSnaps, props.slidesToScroll, props.startIndex]);
  const reinitOptions = useCallback(() => {
    let opts: any = null;
    opts = initialOptions();
    delete opts.startIndex;
    return opts;
  }, [initialOptions]);
  const emblaPluginsFromProps = useCallback(() => {
    const builtins = props.autoplay ? [Autoplay({
      delay: props.autoplayDelay
    })] : [];
    return [...builtins, ...props.plugins];
  }, [props.autoplay, props.autoplayDelay, props.plugins]);
  const thumbsOptionsFromProps = useCallback(() => {
    let opts: any = null;
    opts = {
      containScroll: 'keepSnaps',
      dragFree: true,
      axis: props.axis
    };
    return opts;
  }, [props.axis]);
  const syncNav = useCallback(() => {
    if (!embla.current) return;
    const i = embla.current.selectedScrollSnap();
    setSnaps(embla.current.scrollSnapList().map((_: any, n: any) => n));
    setSelected(i);
    setCanPrev(embla.current.canScrollPrev());
    setCanNext(embla.current.canScrollNext());
    if (emblaThumbs.current) emblaThumbs.current.scrollTo(i);
  }, []);
  const selectThumb = useCallback((i: any) => {
    scrollToIndex(i);
  }, [scrollToIndex]);
  // ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
  // 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
  //  - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
  //    260802-tmo D1: a DELIBERATE behavior change to this published verb —
  //    no-arg reInitCarousel() now PRESERVES the current snap (via
  //    reinitOptions()) instead of resetting to `startIndex` on every call. Pass
  //    raw options to override. See docs/components/embla.md's handle table.
  //  - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
  //    setter collision on Lit/Angular; it's a method, the prop is the two-way value).
  //  - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
  //    bare `scrollTo` expose verb becomes a public method on the Lit custom-element
  //    class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
  //    `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
  //    resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
  //    method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
  //  - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
  //    React `setPlugins` auto-setter) — the get* getter convention. Returns the
  //    live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
  //  - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
  //    progress bars, lazy-load/in-view dots, and directional transitions — no
  //    matching prop, emit, or inherited DOM method — clear.
  //  - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
  function scrollNext(jump?: any) {
    if (embla.current) embla.current.scrollNext(jump);
  }
  function scrollPrev(jump?: any) {
    if (embla.current) embla.current.scrollPrev(jump);
  }
  function scrollToIndex(index: any, jump?: any) {
    if (embla.current) embla.current.scrollTo(index, jump);
  }
  function reInitCarousel(opts: any) {
    if (embla.current) embla.current.reInit(opts ?? reinitOptions(), emblaPluginsFromProps());
  }
  function canScrollNext() {
    return embla.current ? embla.current.canScrollNext() : false;
  }
  function canScrollPrev() {
    return embla.current ? embla.current.canScrollPrev() : false;
  }
  function getSelectedIndex() {
    return embla.current ? embla.current.selectedScrollSnap() : 0;
  }
  function scrollSnapList() {
    return embla.current ? embla.current.scrollSnapList() : [];
  }
  function scrollProgress() {
    return embla.current ? embla.current.scrollProgress() : 0;
  }
  function slidesInView() {
    return embla.current ? embla.current.slidesInView() : [];
  }
  function slidesNotInView() {
    return embla.current ? embla.current.slidesNotInView() : [];
  }
  function previousScrollSnap() {
    return embla.current ? embla.current.previousScrollSnap() : 0;
  }
  function getPlugins() {
    return embla.current ? embla.current.plugins() : null;
  }
  function getInstance() {
    return embla.current;
  }

  const _emblaPluginsFromPropsRef = useRef(emblaPluginsFromProps);
  _emblaPluginsFromPropsRef.current = emblaPluginsFromProps;
  const _initialOptionsRef = useRef(initialOptions);
  _initialOptionsRef.current = initialOptions;
  const _reinitOptionsRef = useRef(reinitOptions);
  _reinitOptionsRef.current = reinitOptions;
  const _thumbsOptionsFromPropsRef = useRef(thumbsOptionsFromProps);
  _thumbsOptionsFromPropsRef.current = thumbsOptionsFromProps;
  useEffect(() => {
    embla.current = EmblaCarousel(viewportEl.current!, _initialOptionsRef.current(), _emblaPluginsFromPropsRef.current());

    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    if (_thumbnailsRef.current && thumbsViewportEl.current) {
      emblaThumbs.current = EmblaCarousel(thumbsViewportEl.current!, _thumbsOptionsFromPropsRef.current());
    }

    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    embla.current.on('select', () => {
      const i = embla.current.selectedScrollSnap();
      setSelectedIndex(i);
      _onSelectRef.current && _onSelectRef.current(i);
      syncNav();
    });
    embla.current.on('settle', () => _onSettleRef.current && _onSettleRef.current());
    embla.current.on('reInit', () => {
      _onReInitRef.current && _onReInitRef.current();
      syncNav();
    });
    embla.current.on('pointerDown', () => _onPointerDownRef.current && _onPointerDownRef.current());
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    embla.current.on('resize', () => syncNav());

    // seed the nav state immediately (covers the already-laid-out case)…
    syncNav();
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    if (typeof requestAnimationFrame === 'function') {
      const remeasure = () => {
        if (embla.current) embla.current.reInit(_reinitOptionsRef.current(), _emblaPluginsFromPropsRef.current());
      };
      remeasureRafOuter.current = requestAnimationFrame(() => {
        remeasureRafInner.current = requestAnimationFrame(remeasure);
      });
      remeasureTimer.current = setTimeout(remeasure, 0);
    }

    // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
    // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
    // fall through their existing `if (embla)` / ternary guards after unmount, so
    // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
    // symmetric — null before mount AND after unmount — instead of calling into a
    // destroyed engine.
    return () => {
      if (remeasureRafOuter.current) cancelAnimationFrame(remeasureRafOuter.current);
      if (remeasureRafInner.current) cancelAnimationFrame(remeasureRafInner.current);
      if (remeasureTimer.current) clearTimeout(remeasureTimer.current);
      remeasureRafOuter.current = null;
      remeasureRafInner.current = null;
      remeasureTimer.current = null;
      if (embla.current) {
        embla.current.destroy();
        embla.current = null;
      }
      if (emblaThumbs.current) {
        emblaThumbs.current.destroy();
        emblaThumbs.current = null;
      }
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const i = selectedIndex;
    if (embla.current && typeof i === 'number' && i !== embla.current.selectedScrollSnap()) embla.current.scrollTo(i);
  }, [selectedIndex]);
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    embla.current?.reInit(reinitOptions());
  }, [props.align, props.axis, props.containScroll, props.direction, props.dragFree, props.draggable, props.duration, props.loop, props.skipSnaps, props.slidesToScroll]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch2First.current) { _watch2First.current = false; return; }
    embla.current?.reInit(reinitOptions(), emblaPluginsFromProps());
  }, [props.autoplay, props.autoplayDelay]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch3First.current) { _watch3First.current = false; return; }
    embla.current?.reInit(reinitOptions());
    emblaThumbs.current?.reInit(thumbsOptionsFromProps());
    syncNav();
  }, [props.slides.length]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch4First.current) { _watch4First.current = false; return; }
    embla.current?.reInit(reinitOptions());
    syncNav();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch5First.current) { _watch5First.current = false; return; }
    const on = props.thumbnails;
    if (!on) {
      if (emblaThumbs.current) {
        emblaThumbs.current.destroy();
        emblaThumbs.current = null;
      }
      return;
    }
    if (emblaThumbs.current) return;
    // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
    // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
    // ViewportEl is still null when the callback runs, so a synchronous build
    // silently no-ops and a runtime thumbnails toggle never gets an engine.
    // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
    // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
    // lands first after the flush wins; targets that already flushed
    // synchronously take the immediate fast path in `build()` below and never
    // wait on the deferred passes.
    const build = () => {
      if (!embla.current) return; // unmounted — the $onMount cleanup nulled it
      if (!props.thumbnails) return; // toggled back off before this pass ran
      if (emblaThumbs.current) return; // idempotent across the double-schedule
      if (!thumbsViewportEl.current) return;
      emblaThumbs.current = EmblaCarousel(thumbsViewportEl.current!, thumbsOptionsFromProps());
      syncNav();
    };
    build();
    if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
  }, [props.thumbnails]); // eslint-disable-line react-hooks/exhaustive-deps

  const _rozieExposeRef = useRef({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance });
  _rozieExposeRef.current = { scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance };
  useImperativeHandle(ref, () => ({ scrollNext: (...args: Parameters<typeof scrollNext>): ReturnType<typeof scrollNext> => _rozieExposeRef.current.scrollNext(...args), scrollPrev: (...args: Parameters<typeof scrollPrev>): ReturnType<typeof scrollPrev> => _rozieExposeRef.current.scrollPrev(...args), scrollToIndex: (...args: Parameters<typeof scrollToIndex>): ReturnType<typeof scrollToIndex> => _rozieExposeRef.current.scrollToIndex(...args), reInitCarousel: (...args: Parameters<typeof reInitCarousel>): ReturnType<typeof reInitCarousel> => _rozieExposeRef.current.reInitCarousel(...args), canScrollNext: (...args: Parameters<typeof canScrollNext>): ReturnType<typeof canScrollNext> => _rozieExposeRef.current.canScrollNext(...args), canScrollPrev: (...args: Parameters<typeof canScrollPrev>): ReturnType<typeof canScrollPrev> => _rozieExposeRef.current.canScrollPrev(...args), getSelectedIndex: (...args: Parameters<typeof getSelectedIndex>): ReturnType<typeof getSelectedIndex> => _rozieExposeRef.current.getSelectedIndex(...args), scrollSnapList: (...args: Parameters<typeof scrollSnapList>): ReturnType<typeof scrollSnapList> => _rozieExposeRef.current.scrollSnapList(...args), scrollProgress: (...args: Parameters<typeof scrollProgress>): ReturnType<typeof scrollProgress> => _rozieExposeRef.current.scrollProgress(...args), slidesInView: (...args: Parameters<typeof slidesInView>): ReturnType<typeof slidesInView> => _rozieExposeRef.current.slidesInView(...args), slidesNotInView: (...args: Parameters<typeof slidesNotInView>): ReturnType<typeof slidesNotInView> => _rozieExposeRef.current.slidesNotInView(...args), previousScrollSnap: (...args: Parameters<typeof previousScrollSnap>): ReturnType<typeof previousScrollSnap> => _rozieExposeRef.current.previousScrollSnap(...args), getPlugins: (...args: Parameters<typeof getPlugins>): ReturnType<typeof getPlugins> => _rozieExposeRef.current.getPlugins(...args), getInstance: (...args: Parameters<typeof getInstance>): ReturnType<typeof getInstance> => _rozieExposeRef.current.getInstance(...args) }), []);

  return (
    <>
    <div {...attrs} className={clsx(clsx("rozie-embla", { "rozie-embla--vertical": props.axis === 'y' }), (attrs.className as string | undefined))} data-rozie-s-4143c216="">
      
      <div className={"rozie-embla__stage"} data-rozie-s-4143c216="">
        {!!(props.arrows) && <button type="button" className={"rozie-embla__arrow rozie-embla__arrow--prev"} disabled={!canPrev} aria-label="Previous slide" onClick={($event) => { scrollPrev(); }} data-rozie-s-4143c216="">‹</button>}<div className={"rozie-embla__viewport"} ref={viewportEl} data-rozie-s-4143c216="">
          <div className={"rozie-embla__container"} data-rozie-s-4143c216="">
            
            {props.slides.map((slide, i) => <div key={keyFor(slide, i)} className={"rozie-embla__slide"} data-rozie-s-4143c216="">
              {(props.renderSlide ?? props.slots?.['slide']) ? ((props.renderSlide ?? props.slots?.['slide']) as Function)({ slide, index: i }) : rozieDisplay(slide)}
            </div>)}
            
            {(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
          </div>
        </div>
        {!!(props.arrows) && <button type="button" className={"rozie-embla__arrow rozie-embla__arrow--next"} disabled={!canNext} aria-label="Next slide" onClick={($event) => { scrollNext(); }} data-rozie-s-4143c216="">›</button>}</div>

      
      {!!(props.dots) && <div className={"rozie-embla__dots"} data-rozie-s-4143c216="">
        {snaps.map((di) => <button key={di} type="button" className={clsx("rozie-embla__dot", { "is-selected": di === selected })} aria-label={rozieAttr('Go to slide ' + (di + 1))} onClick={($event) => { scrollToIndex(di); }} data-rozie-s-4143c216="" />)}
      </div>}{!!(props.thumbnails) && <div className={"rozie-embla__thumbs"} data-rozie-s-4143c216="">
        <div className={"rozie-embla__thumbs-viewport"} ref={thumbsViewportEl} data-rozie-s-4143c216="">
          <div className={"rozie-embla__thumbs-container"} data-rozie-s-4143c216="">
            {props.slides.map((item, i) => <div key={keyFor(item, i)} className={clsx("rozie-embla__thumb", { "is-selected": i === selected })} onClick={($event) => { selectThumb(i); }} data-rozie-s-4143c216="">
              {(props.renderThumb ?? props.slots?.['thumb']) ? ((props.renderThumb ?? props.slots?.['thumb']) as Function)({ slide: item, index: i }) : rozieDisplay(item)}
            </div>)}
          </div>
        </div>
      </div>}</div>
    </>
  );
});
export default Carousel;
vue
<template>

<div :class="['rozie-embla', { 'rozie-embla--vertical': props.axis === 'y' }]" v-bind="$attrs">
  
  <div class="rozie-embla__stage">
    <button v-if="props.arrows" type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" :disabled="!canPrev" aria-label="Previous slide" @click="scrollPrev()">‹</button><div class="rozie-embla__viewport" ref="viewportElRef">
      <div class="rozie-embla__container">
        
        <div v-for="(slide, i) in props.slides" :key="keyFor(slide, i)" class="rozie-embla__slide">
          <slot name="slide" :slide="slide" :index="i">{{ slide }}</slot>
        </div>
        
        <slot></slot>
      </div>
    </div>
    <button v-if="props.arrows" type="button" class="rozie-embla__arrow rozie-embla__arrow--next" :disabled="!canNext" aria-label="Next slide" @click="scrollNext()">›</button></div>

  
  <div v-if="props.dots" class="rozie-embla__dots">
    <button v-for="di in snaps" :key="di" type="button" :class="['rozie-embla__dot', { 'is-selected': di === selected }]" :aria-label="'Go to slide ' + (di + 1)" @click="scrollToIndex(di)"></button>
  </div><div v-if="props.thumbnails" class="rozie-embla__thumbs">
    <div class="rozie-embla__thumbs-viewport" ref="thumbsViewportElRef">
      <div class="rozie-embla__thumbs-container">
        <div v-for="(item, i) in props.slides" :key="keyFor(item, i)" :class="['rozie-embla__thumb', { 'is-selected': i === selected }]" @click="selectThumb(i)">
          <slot name="thumb" :slide="item" :index="i">{{ item }}</slot>
        </div>
      </div>
    </div>
  </div></div>

</template>

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

const props = withDefaults(
  defineProps<{
    /**
     * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
     * @example
     * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
     */
    slides?: any[];
    /**
     * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
     */
    loop?: boolean;
    /**
     * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
     */
    align?: string;
    /**
     * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
     */
    axis?: string;
    /**
     * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
     */
    slidesToScroll?: number;
    /**
     * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
     */
    dragFree?: boolean;
    /**
     * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
     */
    draggable?: boolean;
    /**
     * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
     */
    containScroll?: string;
    /**
     * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
     */
    startIndex?: number;
    /**
     * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
     */
    skipSnaps?: boolean;
    /**
     * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
     */
    duration?: number;
    /**
     * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
     */
    direction?: string;
    /**
     * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
     */
    autoplay?: boolean;
    /**
     * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
     */
    autoplayDelay?: number;
    /**
     * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
     */
    dots?: boolean;
    /**
     * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
     */
    arrows?: boolean;
    /**
     * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
     */
    thumbnails?: boolean;
    /**
     * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
     */
    plugins?: any[];
    /**
     * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
     */
    options?: Record<string, any>;
  }>(),
  { slides: () => [], loop: false, align: 'center', axis: 'x', slidesToScroll: 1, dragFree: false, draggable: true, containScroll: 'trimSnaps', startIndex: 0, skipSnaps: false, duration: 25, direction: 'ltr', autoplay: false, autoplayDelay: 4000, dots: false, arrows: false, thumbnails: false, plugins: () => [], options: () => ({}) }
);

/**
 * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
 * @example
 * <Carousel :slides="items" r-model:selectedIndex="idx" />
 */
const selectedIndex = defineModel<number>('selectedIndex', { default: 0 });

const emit = defineEmits<{
  select: [...args: any[]];
  settle: [...args: any[]];
  reInit: [...args: any[]];
  'pointer-down': [...args: any[]];
}>();

defineSlots<{
  slide(props: { slide: any; index: any }): any;
  default(props: {  }): any;
  thumb(props: { slide: any; index: any }): any;
}>();

const snaps = ref<any[]>([]);
const selected = ref(0);
const canPrev = ref(false);
const canNext = ref(false);

const viewportElRef = ref<HTMLElement>();
const thumbsViewportElRef = ref<HTMLElement>();

import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla: any = null;
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs: any = null;
// D7: the two mount-time remeasure rAF handles + the macrotask handle, captured
// so the mount cleanup can cancel them on unmount. Null-lets (not `= 0`) so they
// type-neutralize to `any` — setTimeout's return type differs between the DOM
// and Node lib shapes across the six leaves' tsconfigs.
let remeasureRafOuter: any = null;
let remeasureRafInner: any = null;
let remeasureTimer: any = null;
// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide: any, i: any) => {
  if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
  return slide ?? i;
};
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const initialOptions = () => {
  let opts: any = null;
  opts = {
    // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
    // branch, an explicit element list only on the TRUE branch. This is
    // deliberately NOT an unconditional array — Embla's own `storeElements()`
    // re-resolves a STRING selector fresh via `container.querySelectorAll()`
    // on EVERY `reInit()` call, including its OWN internally-triggered ones
    // (the native `watchSlides: true` MutationObserver calls `reInit()` with
    // NO arguments, which reuses whatever `slides` value was last explicitly
    // set — a materialized ARRAY would freeze at that point, silently
    // breaking watchSlides' self-healing for ANY post-mount slide-count
    // change on ALL SIX targets, not just Lit; verified live via the VR
    // union matrix, not just reasoned about). Keeping the selector string as
    // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
    // behavior — for config-array mode on every target, AND for declarative
    // mode on the five hostless targets, AND for Lit's config-array mode
    // (all of those cases have real shadow/light-DOM descendants the
    // selector correctly re-finds every time, exactly as before this task).
    // `$slotted.default.length` is a compile-time-constant `0` on the five
    // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
    // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
    // always-false runtime check, byte-behavior-identical to the pre-D4
    // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
    // there IS declarative (light-DOM) content assigned to the default slot,
    // does it switch to the explicit list — the phantom-slot problem
    // (`container.children` fallback catching the trailing empty <slot>) is
    // avoided the same way it always was: the selector filters to
    // `.rozie-embla__slide` elements only. `...$props.options` still
    // overrides `slides` last if a consumer needs to.
    slides: [].length > 0 ? [...viewportElRef.value!.querySelectorAll('.rozie-embla__slide'), ...[]] : '.rozie-embla__slide',
    loop: props.loop,
    align: props.align,
    axis: props.axis,
    slidesToScroll: props.slidesToScroll,
    dragFree: props.dragFree,
    watchDrag: props.draggable,
    containScroll: props.containScroll,
    startIndex: props.startIndex,
    skipSnaps: props.skipSnaps,
    duration: props.duration,
    direction: props.direction,
    ...props.options
  };
  return opts;
};
// startIndex is INIT-ONLY. Embla's reActivate preserves the live position by
// merging mergeOptions({ startIndex: selectedScrollSnap() }, withOptions) — and
// withOptions WINS (embla-carousel@8 esm :1450, :1558). So any startIndex left
// in a reInit payload teleports the carousel back to the prop's value on every
// option flip, slide add/remove, and no-arg reInitCarousel(). Delete it AFTER
// the ...$props.options spread so the raw escape hatch cannot reintroduce it
// either. To move programmatically, use the scrollToIndex() handle verb.
const reinitOptions = () => {
  let opts: any = null;
  opts = initialOptions();
  delete opts.startIndex;
  return opts;
};
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
  const builtins = props.autoplay ? [Autoplay({
    delay: props.autoplayDelay
  })] : [];
  return [...builtins, ...props.plugins];
};
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as initialOptions (axis is a `string`).
const thumbsOptionsFromProps = () => {
  let opts: any = null;
  opts = {
    containScroll: 'keepSnaps',
    dragFree: true,
    axis: props.axis
  };
  return opts;
};
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
  if (!embla) return;
  const i = embla.selectedScrollSnap();
  snaps.value = embla.scrollSnapList().map((_: any, n: any) => n);
  selected.value = i;
  canPrev.value = embla.canScrollPrev();
  canNext.value = embla.canScrollNext();
  if (emblaThumbs) emblaThumbs.scrollTo(i);
};
// Thumb click → scroll the MAIN carousel. Calls the $expose'd scrollToIndex verb
// directly (below) — arg-light internal calls to an exposed verb now typecheck
// cleanly on all six targets: the emitter lowers a TRAILING $expose verb param
// optional (`jump?: any` / `index`+`jump?`) whenever it sees a fewer-arg internal
// call site (emitter-hardening backlog item #5). The prior raw-engine
// navPrev/navNext/navTo bypass existed ONLY to dodge the pre-fix required-arg
// TS2554 and is gone now that the compiler owns the arity.
//
// NB: no `clickAllowed()` drag-vs-click guard. Embla 8 dropped `clickAllowed`
// from the public API entirely (it isn't a method on EmblaCarouselType), so the
// old guard threw `TypeError: emblaThumbs.clickAllowed is not a function` on
// every thumb tap. The modern Embla thumbs idiom calls `scrollTo` directly; a
// drag that ends on a thumb simply scrolls, which is acceptable for a nav strip.
const selectThumb = (i: any) => {
  scrollToIndex(i);
};
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
//  - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
//    260802-tmo D1: a DELIBERATE behavior change to this published verb —
//    no-arg reInitCarousel() now PRESERVES the current snap (via
//    reinitOptions()) instead of resetting to `startIndex` on every call. Pass
//    raw options to override. See docs/components/embla.md's handle table.
//  - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
//    setter collision on Lit/Angular; it's a method, the prop is the two-way value).
//  - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
//    bare `scrollTo` expose verb becomes a public method on the Lit custom-element
//    class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
//    `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
//    resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
//    method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
//  - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
//    React `setPlugins` auto-setter) — the get* getter convention. Returns the
//    live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
//  - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
//    progress bars, lazy-load/in-view dots, and directional transitions — no
//    matching prop, emit, or inherited DOM method — clear.
//  - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump?: any) {
  if (embla) embla.scrollNext(jump);
}
function scrollPrev(jump?: any) {
  if (embla) embla.scrollPrev(jump);
}
function scrollToIndex(index: any, jump?: any) {
  if (embla) embla.scrollTo(index, jump);
}
function reInitCarousel(opts: any) {
  if (embla) embla.reInit(opts ?? reinitOptions(), emblaPluginsFromProps());
}
function canScrollNext() {
  return embla ? embla.canScrollNext() : false;
}
function canScrollPrev() {
  return embla ? embla.canScrollPrev() : false;
}
function getSelectedIndex() {
  return embla ? embla.selectedScrollSnap() : 0;
}
function scrollSnapList() {
  return embla ? embla.scrollSnapList() : [];
}
function scrollProgress() {
  return embla ? embla.scrollProgress() : 0;
}
function slidesInView() {
  return embla ? embla.slidesInView() : [];
}
function slidesNotInView() {
  return embla ? embla.slidesNotInView() : [];
}
function previousScrollSnap() {
  return embla ? embla.previousScrollSnap() : 0;
}
function getPlugins() {
  return embla ? embla.plugins() : null;
}
function getInstance() {
  return embla;
}

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  embla = EmblaCarousel(viewportElRef.value!, initialOptions(), emblaPluginsFromProps());

  // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
  // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
  // only $refs-safe site). Stays null otherwise (zero overhead).
  if (props.thumbnails && thumbsViewportElRef.value) {
    emblaThumbs = EmblaCarousel(thumbsViewportElRef.value!, thumbsOptionsFromProps());
  }

  // engine → consumer: on every snap change write the two-way model AND fire the
  // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
  // refreshes the built-in dots/arrows + thumb sync.
  embla.on('select', () => {
    const i = embla.selectedScrollSnap();
    selectedIndex.value = i;
    emit('select', i);
    syncNav();
  });
  embla.on('settle', () => emit('settle'));
  embla.on('reInit', () => {
    emit('reInit');
    syncNav();
  });
  embla.on('pointerDown', () => emit('pointer-down'));
  // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
  // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
  // at init is stale — and a slide-size change (vs a viewport resize or slide
  // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
  // its own. Re-measure once after the first layout flush via reInit (its `reInit`
  // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
  embla.on('resize', () => syncNav());

  // seed the nav state immediately (covers the already-laid-out case)…
  syncNav();
  // …then re-measure after layout fully settles (a consumer's slide CSS / a root
  // width via attribute fallthrough can land a couple of frames after $onMount;
  // Embla caches slide sizes at init and a slide-size change alone fires no
  // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
  // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
  if (typeof requestAnimationFrame === 'function') {
    const remeasure = () => {
      if (embla) embla.reInit(reinitOptions(), emblaPluginsFromProps());
    };
    remeasureRafOuter = requestAnimationFrame(() => {
      remeasureRafInner = requestAnimationFrame(remeasure);
    });
    remeasureTimer = setTimeout(remeasure, 0);
  }

  // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
  // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
  // fall through their existing `if (embla)` / ternary guards after unmount, so
  // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
  // symmetric — null before mount AND after unmount — instead of calling into a
  // destroyed engine.
  _cleanup_0 = () => {
    if (remeasureRafOuter) cancelAnimationFrame(remeasureRafOuter);
    if (remeasureRafInner) cancelAnimationFrame(remeasureRafInner);
    if (remeasureTimer) clearTimeout(remeasureTimer);
    remeasureRafOuter = null;
    remeasureRafInner = null;
    remeasureTimer = null;
    if (embla) {
      embla.destroy();
      embla = null;
    }
    if (emblaThumbs) {
      emblaThumbs.destroy();
      emblaThumbs = null;
    }
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => selectedIndex.value, (i: any) => {
  if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
}, { flush: 'post' });
watch(() => [props.loop, props.align, props.axis, props.slidesToScroll, props.dragFree, props.draggable, props.containScroll, props.skipSnaps, props.duration, props.direction].join('|'), () => embla?.reInit(reinitOptions()), { flush: 'post' });
watch(() => `${props.autoplay}|${props.autoplayDelay}`, () => embla?.reInit(reinitOptions(), emblaPluginsFromProps()), { flush: 'post' });
watch(() => props.slides.length, () => {
  embla?.reInit(reinitOptions());
  emblaThumbs?.reInit(thumbsOptionsFromProps());
  syncNav();
}, { flush: 'post' });
watch(() => [].length, () => {
  embla?.reInit(reinitOptions());
  syncNav();
}, { flush: 'post' });
watch(() => props.thumbnails, (on: any) => {
  if (!on) {
    if (emblaThumbs) {
      emblaThumbs.destroy();
      emblaThumbs = null;
    }
    return;
  }
  if (emblaThumbs) return;
  // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
  // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
  // ViewportEl is still null when the callback runs, so a synchronous build
  // silently no-ops and a runtime thumbnails toggle never gets an engine.
  // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
  // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
  // lands first after the flush wins; targets that already flushed
  // synchronously take the immediate fast path in `build()` below and never
  // wait on the deferred passes.
  const build = () => {
    if (!embla) return; // unmounted — the $onMount cleanup nulled it
    if (!props.thumbnails) return; // toggled back off before this pass ran
    if (emblaThumbs) return; // idempotent across the double-schedule
    if (!thumbsViewportElRef.value) return;
    emblaThumbs = EmblaCarousel(thumbsViewportElRef.value!, thumbsOptionsFromProps());
    syncNav();
  };
  build();
  if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
}, { flush: 'post' });

defineExpose({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance });
</script>

<style scoped>
.rozie-embla { position: relative; }
.rozie-embla__stage { position: relative; }
.rozie-embla__viewport { overflow: hidden; }
.rozie-embla__container { display: flex; }
.rozie-embla__slide { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
.rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-embla-arrow-size, 2.25rem);
  height: var(--rozie-embla-arrow-size, 2.25rem);
  padding: 0;
  border: none;
  border-radius: var(--rozie-embla-arrow-radius, 50%);
  background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
  color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
  font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
  transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
.rozie-embla__arrow:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
.rozie-embla__arrow--prev { left: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__arrow--next { right: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__dots {
  display: flex;
  justify-content: center;
  gap: var(--rozie-embla-dots-gap, 0.4rem);
  padding: var(--rozie-embla-dots-padding, 0.625rem 0);
}
.rozie-embla__dot {
  width: var(--rozie-embla-dot-size, 0.5rem);
  height: var(--rozie-embla-dot-size, 0.5rem);
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
.rozie-embla__dot.is-selected {
  background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
  transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
}
.rozie-embla__thumbs { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
.rozie-embla__thumbs-viewport { overflow: hidden; }
.rozie-embla__thumbs-container { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
.rozie-embla__thumb {
  flex: 0 0 auto;
  cursor: pointer;
  opacity: var(--rozie-embla-thumb-opacity, 0.5);
  border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
  border-radius: var(--rozie-embla-thumb-radius, 4px);
  overflow: hidden;
  transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
.rozie-embla__thumb.is-selected {
  opacity: var(--rozie-embla-thumb-selected-opacity, 1);
  border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';

interface Props {
  /**
   * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
   * @example
   * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
   */
  slides?: any[];
  /**
   * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
   */
  loop?: boolean;
  /**
   * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
   */
  align?: string;
  /**
   * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
   */
  axis?: string;
  /**
   * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
   */
  slidesToScroll?: number;
  /**
   * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
   */
  dragFree?: boolean;
  /**
   * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
   */
  draggable?: boolean;
  /**
   * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
   */
  containScroll?: string;
  /**
   * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
   */
  startIndex?: number;
  /**
   * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
   */
  skipSnaps?: boolean;
  /**
   * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
   */
  duration?: number;
  /**
   * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
   */
  direction?: string;
  /**
   * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
   */
  autoplay?: boolean;
  /**
   * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
   */
  autoplayDelay?: number;
  /**
   * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
   */
  dots?: boolean;
  /**
   * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
   */
  arrows?: boolean;
  /**
   * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
   */
  thumbnails?: boolean;
  /**
   * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
   */
  plugins?: any[];
  /**
   * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
   */
  options?: any;
  /**
   * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
   * @example
   * <Carousel :slides="items" r-model:selectedIndex="idx" />
   */
  selectedIndex?: number;
  slide?: Snippet<[{ slide: any; index: any }]>;
  children?: Snippet;
  thumb?: Snippet<[{ slide: any; index: any }]>;
  snippets?: Record<string, any>;
  onselect?: (...args: unknown[]) => void;
  onsettle?: (...args: unknown[]) => void;
  onreinit?: (...args: unknown[]) => void;
  onpointerdown?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let __defaultSlides = (() => [])();
let __defaultPlugins = (() => [])();
let __defaultOptions = (() => ({}))();

let {
  slides = __defaultSlides,
  loop = false,
  align = 'center',
  axis = 'x',
  slidesToScroll = 1,
  dragFree = false,
  draggable = true,
  containScroll = 'trimSnaps',
  startIndex = 0,
  skipSnaps = false,
  duration = 25,
  direction = 'ltr',
  autoplay = false,
  autoplayDelay = 4000,
  dots = false,
  arrows = false,
  thumbnails = false,
  plugins = __defaultPlugins,
  options = __defaultOptions,
  selectedIndex = $bindable(0),
  slide: __slideProp,
  children: __childrenProp,
  thumb: __thumbProp,
  snippets,
  onselect,
  onsettle,
  onreinit,
  onpointerdown,
  ...__rozieAttrs
}: Props = $props();

const slide$$slot = $derived(__slideProp ?? snippets?.slide);
const children = $derived(__childrenProp ?? snippets?.children);
const thumb = $derived(__thumbProp ?? snippets?.thumb);

let snaps: any[] = $state([]);
let selected = $state(0);
let canPrev = $state(false);
let canNext = $state(false);

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

import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla: any = null;
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs: any = null;
// D7: the two mount-time remeasure rAF handles + the macrotask handle, captured
// so the mount cleanup can cancel them on unmount. Null-lets (not `= 0`) so they
// type-neutralize to `any` — setTimeout's return type differs between the DOM
// and Node lib shapes across the six leaves' tsconfigs.
let remeasureRafOuter: any = null;
let remeasureRafInner: any = null;
let remeasureTimer: any = null;
// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide: any, i: any) => {
  if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
  return slide ?? i;
};
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const initialOptions = () => {
  let opts: any = null;
  opts = {
    // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
    // branch, an explicit element list only on the TRUE branch. This is
    // deliberately NOT an unconditional array — Embla's own `storeElements()`
    // re-resolves a STRING selector fresh via `container.querySelectorAll()`
    // on EVERY `reInit()` call, including its OWN internally-triggered ones
    // (the native `watchSlides: true` MutationObserver calls `reInit()` with
    // NO arguments, which reuses whatever `slides` value was last explicitly
    // set — a materialized ARRAY would freeze at that point, silently
    // breaking watchSlides' self-healing for ANY post-mount slide-count
    // change on ALL SIX targets, not just Lit; verified live via the VR
    // union matrix, not just reasoned about). Keeping the selector string as
    // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
    // behavior — for config-array mode on every target, AND for declarative
    // mode on the five hostless targets, AND for Lit's config-array mode
    // (all of those cases have real shadow/light-DOM descendants the
    // selector correctly re-finds every time, exactly as before this task).
    // `$slotted.default.length` is a compile-time-constant `0` on the five
    // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
    // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
    // always-false runtime check, byte-behavior-identical to the pre-D4
    // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
    // there IS declarative (light-DOM) content assigned to the default slot,
    // does it switch to the explicit list — the phantom-slot problem
    // (`container.children` fallback catching the trailing empty <slot>) is
    // avoided the same way it always was: the selector filters to
    // `.rozie-embla__slide` elements only. `...$props.options` still
    // overrides `slides` last if a consumer needs to.
    slides: [].length > 0 ? [...viewportEl!.querySelectorAll('.rozie-embla__slide'), ...[]] : '.rozie-embla__slide',
    loop: loop,
    align: align,
    axis: axis,
    slidesToScroll: slidesToScroll,
    dragFree: dragFree,
    watchDrag: draggable,
    containScroll: containScroll,
    startIndex: startIndex,
    skipSnaps: skipSnaps,
    duration: duration,
    direction: direction,
    ...options
  };
  return opts;
};
// startIndex is INIT-ONLY. Embla's reActivate preserves the live position by
// merging mergeOptions({ startIndex: selectedScrollSnap() }, withOptions) — and
// withOptions WINS (embla-carousel@8 esm :1450, :1558). So any startIndex left
// in a reInit payload teleports the carousel back to the prop's value on every
// option flip, slide add/remove, and no-arg reInitCarousel(). Delete it AFTER
// the ...$props.options spread so the raw escape hatch cannot reintroduce it
// either. To move programmatically, use the scrollToIndex() handle verb.
const reinitOptions = () => {
  let opts: any = null;
  opts = initialOptions();
  delete opts.startIndex;
  return opts;
};
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
  const builtins = autoplay ? [Autoplay({
    delay: autoplayDelay
  })] : [];
  return [...builtins, ...plugins];
};
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as initialOptions (axis is a `string`).
const thumbsOptionsFromProps = () => {
  let opts: any = null;
  opts = {
    containScroll: 'keepSnaps',
    dragFree: true,
    axis: axis
  };
  return opts;
};
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
  if (!embla) return;
  const i = embla.selectedScrollSnap();
  snaps = embla.scrollSnapList().map((_: any, n: any) => n);
  selected = i;
  canPrev = embla.canScrollPrev();
  canNext = embla.canScrollNext();
  if (emblaThumbs) emblaThumbs.scrollTo(i);
};
// Thumb click → scroll the MAIN carousel. Calls the $expose'd scrollToIndex verb
// directly (below) — arg-light internal calls to an exposed verb now typecheck
// cleanly on all six targets: the emitter lowers a TRAILING $expose verb param
// optional (`jump?: any` / `index`+`jump?`) whenever it sees a fewer-arg internal
// call site (emitter-hardening backlog item #5). The prior raw-engine
// navPrev/navNext/navTo bypass existed ONLY to dodge the pre-fix required-arg
// TS2554 and is gone now that the compiler owns the arity.
//
// NB: no `clickAllowed()` drag-vs-click guard. Embla 8 dropped `clickAllowed`
// from the public API entirely (it isn't a method on EmblaCarouselType), so the
// old guard threw `TypeError: emblaThumbs.clickAllowed is not a function` on
// every thumb tap. The modern Embla thumbs idiom calls `scrollTo` directly; a
// drag that ends on a thumb simply scrolls, which is acceptable for a nav strip.
const selectThumb = (i: any) => {
  scrollToIndex(i);
};
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
//  - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
//    260802-tmo D1: a DELIBERATE behavior change to this published verb —
//    no-arg reInitCarousel() now PRESERVES the current snap (via
//    reinitOptions()) instead of resetting to `startIndex` on every call. Pass
//    raw options to override. See docs/components/embla.md's handle table.
//  - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
//    setter collision on Lit/Angular; it's a method, the prop is the two-way value).
//  - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
//    bare `scrollTo` expose verb becomes a public method on the Lit custom-element
//    class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
//    `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
//    resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
//    method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
//  - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
//    React `setPlugins` auto-setter) — the get* getter convention. Returns the
//    live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
//  - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
//    progress bars, lazy-load/in-view dots, and directional transitions — no
//    matching prop, emit, or inherited DOM method — clear.
//  - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
export function scrollNext(jump?: any) {
  if (embla) embla.scrollNext(jump);
}
export function scrollPrev(jump?: any) {
  if (embla) embla.scrollPrev(jump);
}
export function scrollToIndex(index: any, jump?: any) {
  if (embla) embla.scrollTo(index, jump);
}
export function reInitCarousel(opts: any) {
  if (embla) embla.reInit(opts ?? reinitOptions(), emblaPluginsFromProps());
}
export function canScrollNext() {
  return embla ? embla.canScrollNext() : false;
}
export function canScrollPrev() {
  return embla ? embla.canScrollPrev() : false;
}
export function getSelectedIndex() {
  return embla ? embla.selectedScrollSnap() : 0;
}
export function scrollSnapList() {
  return embla ? embla.scrollSnapList() : [];
}
export function scrollProgress() {
  return embla ? embla.scrollProgress() : 0;
}
export function slidesInView() {
  return embla ? embla.slidesInView() : [];
}
export function slidesNotInView() {
  return embla ? embla.slidesNotInView() : [];
}
export function previousScrollSnap() {
  return embla ? embla.previousScrollSnap() : 0;
}
export function getPlugins() {
  return embla ? embla.plugins() : null;
}
export function getInstance() {
  return embla;
}

onMount(() => {
  embla = EmblaCarousel(viewportEl!, initialOptions(), emblaPluginsFromProps());

  // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
  // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
  // only $refs-safe site). Stays null otherwise (zero overhead).
  if (thumbnails && thumbsViewportEl) {
    emblaThumbs = EmblaCarousel(thumbsViewportEl!, thumbsOptionsFromProps());
  }

  // engine → consumer: on every snap change write the two-way model AND fire the
  // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
  // refreshes the built-in dots/arrows + thumb sync.
  embla.on('select', () => {
    const i = embla.selectedScrollSnap();
    selectedIndex = i;
    onselect?.(i);
    syncNav();
  });
  embla.on('settle', () => onsettle?.());
  embla.on('reInit', () => {
    onreinit?.();
    syncNav();
  });
  embla.on('pointerDown', () => onpointerdown?.());
  // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
  // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
  // at init is stale — and a slide-size change (vs a viewport resize or slide
  // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
  // its own. Re-measure once after the first layout flush via reInit (its `reInit`
  // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
  embla.on('resize', () => syncNav());

  // seed the nav state immediately (covers the already-laid-out case)…
  syncNav();
  // …then re-measure after layout fully settles (a consumer's slide CSS / a root
  // width via attribute fallthrough can land a couple of frames after $onMount;
  // Embla caches slide sizes at init and a slide-size change alone fires no
  // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
  // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
  if (typeof requestAnimationFrame === 'function') {
    const remeasure = () => {
      if (embla) embla.reInit(reinitOptions(), emblaPluginsFromProps());
    };
    remeasureRafOuter = requestAnimationFrame(() => {
      remeasureRafInner = requestAnimationFrame(remeasure);
    });
    remeasureTimer = setTimeout(remeasure, 0);
  }

  // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
  // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
  // fall through their existing `if (embla)` / ternary guards after unmount, so
  // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
  // symmetric — null before mount AND after unmount — instead of calling into a
  // destroyed engine.
  return () => {
    if (remeasureRafOuter) cancelAnimationFrame(remeasureRafOuter);
    if (remeasureRafInner) cancelAnimationFrame(remeasureRafInner);
    if (remeasureTimer) clearTimeout(remeasureTimer);
    remeasureRafOuter = null;
    remeasureRafInner = null;
    remeasureTimer = null;
    if (embla) {
      embla.destroy();
      embla = null;
    }
    if (emblaThumbs) {
      emblaThumbs.destroy();
      emblaThumbs = null;
    }
  };
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => selectedIndex)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((i: any) => {
  if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => [loop, align, axis, slidesToScroll, dragFree, draggable, containScroll, skipSnaps, duration, direction].join('|'))(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => embla?.reInit(reinitOptions()))(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => `${autoplay}|${autoplayDelay}`)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => embla?.reInit(reinitOptions(), emblaPluginsFromProps()))(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { (() => slides.length)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } (() => {
  embla?.reInit(reinitOptions());
  emblaThumbs?.reInit(thumbsOptionsFromProps());
  syncNav();
})(); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { (() => [].length)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } (() => {
  embla?.reInit(reinitOptions());
  syncNav();
})(); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => thumbnails)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((on: any) => {
  if (!on) {
    if (emblaThumbs) {
      emblaThumbs.destroy();
      emblaThumbs = null;
    }
    return;
  }
  if (emblaThumbs) return;
  // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
  // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
  // ViewportEl is still null when the callback runs, so a synchronous build
  // silently no-ops and a runtime thumbnails toggle never gets an engine.
  // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
  // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
  // lands first after the flush wins; targets that already flushed
  // synchronously take the immediate fast path in `build()` below and never
  // wait on the deferred passes.
  const build = () => {
    if (!embla) return; // unmounted — the $onMount cleanup nulled it
    if (!thumbnails) return; // toggled back off before this pass ran
    if (emblaThumbs) return; // idempotent across the double-schedule
    if (!thumbsViewportEl) return;
    emblaThumbs = EmblaCarousel(thumbsViewportEl!, thumbsOptionsFromProps());
    syncNav();
  };
  build();
  if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
})(__watchVal); }); });
</script>

<div {...__rozieAttrs} class={["rozie-embla", { 'rozie-embla--vertical': axis === 'y' }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-4143c216><div class="rozie-embla__stage" data-rozie-s-4143c216>{#if arrows}<button type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" disabled={!canPrev} aria-label="Previous slide" onclick={($event) => { scrollPrev(); }} data-rozie-s-4143c216>‹</button>{/if}<div class="rozie-embla__viewport" bind:this={viewportEl} data-rozie-s-4143c216><div class="rozie-embla__container" data-rozie-s-4143c216>{#each slides as slide, i (keyFor(slide, i))}<div class="rozie-embla__slide" data-rozie-s-4143c216>{#if slide$$slot}{@render slide$$slot({ slide, index: i })}{:else}{rozieDisplay(slide)}{/if}</div>{/each}{@render children?.()}</div></div>{#if arrows}<button type="button" class="rozie-embla__arrow rozie-embla__arrow--next" disabled={!canNext} aria-label="Next slide" onclick={($event) => { scrollNext(); }} data-rozie-s-4143c216>›</button>{/if}</div>{#if dots}<div class="rozie-embla__dots" data-rozie-s-4143c216>{#each snaps as di (di)}<button type="button" class={["rozie-embla__dot", { 'is-selected': di === selected }]} aria-label={rozieAttr('Go to slide ' + (di + 1))} onclick={($event) => { scrollToIndex(di); }} data-rozie-s-4143c216></button>{/each}</div>{/if}{#if thumbnails}<div class="rozie-embla__thumbs" data-rozie-s-4143c216><div class="rozie-embla__thumbs-viewport" bind:this={thumbsViewportEl} data-rozie-s-4143c216><div class="rozie-embla__thumbs-container" data-rozie-s-4143c216>{#each slides as item, i (keyFor(item, i))}<div class={["rozie-embla__thumb", { 'is-selected': i === selected }]} onclick={($event) => { selectThumb(i); }} data-rozie-s-4143c216>{#if thumb}{@render thumb({ slide: item, index: i })}{:else}{rozieDisplay(item)}{/if}</div>{/each}</div></div></div>{/if}</div>

<style>
:global {
  .rozie-embla[data-rozie-s-4143c216] { position: relative; }
  .rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
  .rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
  .rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
  .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
  .rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
  .rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
  .rozie-embla__arrow[data-rozie-s-4143c216] {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--rozie-embla-arrow-size, 2.25rem);
    height: var(--rozie-embla-arrow-size, 2.25rem);
    padding: 0;
    border: none;
    border-radius: var(--rozie-embla-arrow-radius, 50%);
    background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
    color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
    font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
    line-height: 1;
    cursor: pointer;
    box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
    transition: opacity 0.15s ease, background 0.15s ease;
  }
  .rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
  .rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
  .rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: var(--rozie-embla-arrow-inset, 0.5rem); }
  .rozie-embla__arrow--next[data-rozie-s-4143c216] { right: var(--rozie-embla-arrow-inset, 0.5rem); }
  .rozie-embla__dots[data-rozie-s-4143c216] {
    display: flex;
    justify-content: center;
    gap: var(--rozie-embla-dots-gap, 0.4rem);
    padding: var(--rozie-embla-dots-padding, 0.625rem 0);
  }
  .rozie-embla__dot[data-rozie-s-4143c216] {
    width: var(--rozie-embla-dot-size, 0.5rem);
    height: var(--rozie-embla-dot-size, 0.5rem);
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
    cursor: pointer;
    transition: background 0.15s ease, transform 0.15s ease;
  }
  .rozie-embla__dot[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
  .rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
    background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
    transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
  }
  .rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
  .rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
  .rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
  .rozie-embla__thumb[data-rozie-s-4143c216] {
    flex: 0 0 auto;
    cursor: pointer;
    opacity: var(--rozie-embla-thumb-opacity, 0.5);
    border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
    border-radius: var(--rozie-embla-thumb-radius, 4px);
    overflow: hidden;
    transition: opacity 0.15s ease, border-color 0.15s ease;
  }
  .rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
  .rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
    opacity: var(--rozie-embla-thumb-selected-opacity, 1);
    border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';

// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.

interface SlideCtx {
  $implicit: { slide: any; index: any };
  slide: any;
  index: any;
}

interface DefaultCtx {}

interface ThumbCtx {
  $implicit: { slide: any; index: any };
  slide: any;
  index: any;
}

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

    <div class="rozie-embla" [ngClass]="{ 'rozie-embla--vertical': axis() === 'y' }" #rozieSpread_0 #rozieListenersTarget_1>
      
      <div class="rozie-embla__stage">
        @if (arrows()) {
    <button type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" [disabled]="!canPrev()" aria-label="Previous slide" (click)="scrollPrev()">‹</button>
    }<div class="rozie-embla__viewport" #viewportEl>
          <div class="rozie-embla__container">
            
            @for (slide of slides(); track keyFor(slide, i); let i = $index) {
    <div class="rozie-embla__slide">
              @if ((slideTpl ?? __rozieFillMap()['slide'] ?? templates()?.['slide'])) {
    <ng-container *ngTemplateOutlet="(slideTpl ?? __rozieFillMap()['slide'] ?? templates()?.['slide']); context: { $implicit: { slide: slide, index: i }, slide: slide, index: i }" />
    } @else {
    {{ rozieDisplay(slide) }}
    }
            </div>
    }
            
            <ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" />
          </div>
        </div>
        @if (arrows()) {
    <button type="button" class="rozie-embla__arrow rozie-embla__arrow--next" [disabled]="!canNext()" aria-label="Next slide" (click)="scrollNext()">›</button>
    }</div>

      
      @if (dots()) {
    <div class="rozie-embla__dots">
        @for (di of snaps(); track di) {
    <button type="button" class="rozie-embla__dot" [ngClass]="{ 'is-selected': di === selected() }" [attr.aria-label]="rozieAttr('Go to slide ' + (di + 1))" (click)="scrollToIndex(di)"></button>
    }
      </div>
    }@if (thumbnails()) {
    <div class="rozie-embla__thumbs">
        <div class="rozie-embla__thumbs-viewport" #thumbsViewportEl>
          <div class="rozie-embla__thumbs-container">
            @for (item of slides(); track keyFor(item, i); let i = $index) {
    <div class="rozie-embla__thumb" [ngClass]="{ 'is-selected': i === selected() }" (click)="selectThumb(i)">
              @if ((thumbTpl ?? __rozieFillMap()['thumb'] ?? templates()?.['thumb'])) {
    <ng-container *ngTemplateOutlet="(thumbTpl ?? __rozieFillMap()['thumb'] ?? templates()?.['thumb']); context: { $implicit: { slide: item, index: i }, slide: item, index: i }" />
    } @else {
    {{ rozieDisplay(item) }}
    }
            </div>
    }
          </div>
        </div>
      </div>
    }</div>

  `,
  styles: [`
    :host(rozie-carousel) { display: contents; }
    .rozie-embla { position: relative; }
    .rozie-embla__stage { position: relative; }
    .rozie-embla__viewport { overflow: hidden; }
    .rozie-embla__container { display: flex; }
    .rozie-embla__slide { flex: 0 0 100%; min-width: 0; }
    .rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
    .rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }
    .rozie-embla__arrow {
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
      z-index: 2;
      display: flex;
      align-items: center;
      justify-content: center;
      width: var(--rozie-embla-arrow-size, 2.25rem);
      height: var(--rozie-embla-arrow-size, 2.25rem);
      padding: 0;
      border: none;
      border-radius: var(--rozie-embla-arrow-radius, 50%);
      background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
      color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
      font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
      line-height: 1;
      cursor: pointer;
      box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
      transition: opacity 0.15s ease, background 0.15s ease;
    }
    .rozie-embla__arrow:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
    .rozie-embla__arrow:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
    .rozie-embla__arrow--prev { left: var(--rozie-embla-arrow-inset, 0.5rem); }
    .rozie-embla__arrow--next { right: var(--rozie-embla-arrow-inset, 0.5rem); }
    .rozie-embla__dots {
      display: flex;
      justify-content: center;
      gap: var(--rozie-embla-dots-gap, 0.4rem);
      padding: var(--rozie-embla-dots-padding, 0.625rem 0);
    }
    .rozie-embla__dot {
      width: var(--rozie-embla-dot-size, 0.5rem);
      height: var(--rozie-embla-dot-size, 0.5rem);
      padding: 0;
      border: none;
      border-radius: 50%;
      background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
      cursor: pointer;
      transition: background 0.15s ease, transform 0.15s ease;
    }
    .rozie-embla__dot:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
    .rozie-embla__dot.is-selected {
      background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
      transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
    }
    .rozie-embla__thumbs { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
    .rozie-embla__thumbs-viewport { overflow: hidden; }
    .rozie-embla__thumbs-container { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
    .rozie-embla__thumb {
      flex: 0 0 auto;
      cursor: pointer;
      opacity: var(--rozie-embla-thumb-opacity, 0.5);
      border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
      border-radius: var(--rozie-embla-thumb-radius, 4px);
      overflow: hidden;
      transition: opacity 0.15s ease, border-color 0.15s ease;
    }
    .rozie-embla__thumb:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
    .rozie-embla__thumb.is-selected {
      opacity: var(--rozie-embla-thumb-selected-opacity, 1);
      border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Carousel),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Carousel {
  /**
   * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
   * @example
   * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
   */
  slides = input<any[]>((() => [])());
  /**
   * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
   */
  loop = input<boolean>(false);
  /**
   * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
   */
  align = input<string>('center');
  /**
   * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
   */
  axis = input<string>('x');
  /**
   * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
   */
  slidesToScroll = input<number>(1);
  /**
   * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
   */
  dragFree = input<boolean>(false);
  /**
   * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
   */
  draggable = input<boolean>(true);
  /**
   * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
   */
  containScroll = input<string>('trimSnaps');
  /**
   * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
   */
  startIndex = input<number>(0);
  /**
   * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
   */
  skipSnaps = input<boolean>(false);
  /**
   * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
   */
  duration = input<number>(25);
  /**
   * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
   */
  direction = input<string>('ltr');
  /**
   * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
   */
  autoplay = input<boolean>(false);
  /**
   * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
   */
  autoplayDelay = input<number>(4000);
  /**
   * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
   */
  dots = input<boolean>(false);
  /**
   * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
   */
  arrows = input<boolean>(false);
  /**
   * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
   */
  thumbnails = input<boolean>(false);
  /**
   * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
   */
  plugins = input<any[]>((() => [])());
  /**
   * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
   */
  options = input<Record<string, any>>((() => ({}))());
  /**
   * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
   * @example
   * <Carousel :slides="items" r-model:selectedIndex="idx" />
   */
  selectedIndex = model<number>(0);
  snaps = signal<any[]>([]);
  selected = signal(0);
  canPrev = signal(false);
  canNext = signal(false);
  viewportEl = viewChild<ElementRef<HTMLDivElement>>('viewportEl');
  thumbsViewportEl = viewChild<ElementRef<HTMLDivElement>>('thumbsViewportEl');
  select = output<unknown>();
  settle = output<void>();
  reInit = output<void>();
  pointerDown = output<void>({ alias: 'pointer-down' });
  @ContentChild('slide', { read: TemplateRef }) slideTpl?: TemplateRef<SlideCtx>;
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  @ContentChild('thumb', { read: TemplateRef }) thumbTpl?: TemplateRef<ThumbCtx>;
  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 __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;

  constructor() {
    effect(() => { const __watchVal = (() => this.selectedIndex())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((i: any) => {
      if (this.embla && typeof i === 'number' && i !== this.embla.selectedScrollSnap()) this.embla.scrollTo(i);
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => [this.loop(), this.align(), this.axis(), this.slidesToScroll(), this.dragFree(), this.draggable(), this.containScroll(), this.skipSnaps(), this.duration(), this.direction()].join('|'))(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => this.embla?.reInit(this.reinitOptions()))(); }); });
    effect(() => { const __watchVal = (() => `${this.autoplay()}|${this.autoplayDelay()}`)(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => this.embla?.reInit(this.reinitOptions(), this.emblaPluginsFromProps()))(); }); });
    effect(() => { const __watchVal = (() => this.slides().length)(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } (() => {
      this.embla?.reInit(this.reinitOptions());
      this.emblaThumbs?.reInit(this.thumbsOptionsFromProps());
      this.syncNav();
    })(); }); });
    effect(() => { const __watchVal = (() => [].length)(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } (() => {
      this.embla?.reInit(this.reinitOptions());
      this.syncNav();
    })(); }); });
    effect(() => { const __watchVal = (() => this.thumbnails())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((on: any) => {
      if (!on) {
        if (this.emblaThumbs) {
          this.emblaThumbs.destroy();
          this.emblaThumbs = null;
        }
        return;
      }
      if (this.emblaThumbs) return;
      // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
      // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
      // ViewportEl is still null when the callback runs, so a synchronous build
      // silently no-ops and a runtime thumbnails toggle never gets an engine.
      // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
      // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
      // lands first after the flush wins; targets that already flushed
      // synchronously take the immediate fast path in `build()` below and never
      // wait on the deferred passes.
      const build = () => {
        if (!this.embla) return; // unmounted — the $onMount cleanup nulled it
        if (!this.thumbnails()) return; // toggled back off before this pass ran
        if (this.emblaThumbs) return; // idempotent across the double-schedule
        if (!this.thumbsViewportEl()?.nativeElement) return;
        this.emblaThumbs = EmblaCarousel(this.thumbsViewportEl()!.nativeElement, this.thumbsOptionsFromProps());
        this.syncNav();
      };
      build();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
    })(__watchVal); }); });
  }

  ngAfterViewInit() {
    this.embla = EmblaCarousel(this.viewportEl()!.nativeElement, this.initialOptions(), this.emblaPluginsFromProps());

    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    if (this.thumbnails() && this.thumbsViewportEl()?.nativeElement) {
      this.emblaThumbs = EmblaCarousel(this.thumbsViewportEl()!.nativeElement, this.thumbsOptionsFromProps());
    }

    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    this.embla.on('select', () => {
      const i = this.embla.selectedScrollSnap();
      this.selectedIndex.set(i), this.__rozieCvaOnChange(i);
      this.select.emit(i);
      this.syncNav();
    });
    this.embla.on('settle', () => this.settle.emit());
    this.embla.on('reInit', () => {
      this.reInit.emit();
      this.syncNav();
    });
    this.embla.on('pointerDown', () => this.pointerDown.emit());
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    this.embla.on('resize', () => this.syncNav());

    // seed the nav state immediately (covers the already-laid-out case)…
    // seed the nav state immediately (covers the already-laid-out case)…
    this.syncNav();
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    if (typeof requestAnimationFrame === 'function') {
      const remeasure = () => {
        if (this.embla) this.embla.reInit(this.reinitOptions(), this.emblaPluginsFromProps());
      };
      this.remeasureRafOuter = requestAnimationFrame(() => {
        this.remeasureRafInner = requestAnimationFrame(remeasure);
      });
      this.remeasureTimer = setTimeout(remeasure, 0);
    }

    // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
    // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
    // fall through their existing `if (embla)` / ternary guards after unmount, so
    // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
    // symmetric — null before mount AND after unmount — instead of calling into a
    // destroyed engine.
    this.__rozieDestroyRef.onDestroy(() => {
      if (this.remeasureRafOuter) cancelAnimationFrame(this.remeasureRafOuter);
      if (this.remeasureRafInner) cancelAnimationFrame(this.remeasureRafInner);
      if (this.remeasureTimer) clearTimeout(this.remeasureTimer);
      this.remeasureRafOuter = null;
      this.remeasureRafInner = null;
      this.remeasureTimer = null;
      if (this.embla) {
        this.embla.destroy();
        this.embla = null;
      }
      if (this.emblaThumbs) {
        this.emblaThumbs.destroy();
        this.emblaThumbs = null;
      }
    });
  }

  embla: any = null;
  emblaThumbs: any = null;
  remeasureRafOuter: any = null;
  remeasureRafInner: any = null;
  remeasureTimer: any = null;
  keyFor = (slide: any, i: any) => {
    if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
    return slide ?? i;
  };
  initialOptions = () => {
    let opts: any = null;
    opts = {
      // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
      // branch, an explicit element list only on the TRUE branch. This is
      // deliberately NOT an unconditional array — Embla's own `storeElements()`
      // re-resolves a STRING selector fresh via `container.querySelectorAll()`
      // on EVERY `reInit()` call, including its OWN internally-triggered ones
      // (the native `watchSlides: true` MutationObserver calls `reInit()` with
      // NO arguments, which reuses whatever `slides` value was last explicitly
      // set — a materialized ARRAY would freeze at that point, silently
      // breaking watchSlides' self-healing for ANY post-mount slide-count
      // change on ALL SIX targets, not just Lit; verified live via the VR
      // union matrix, not just reasoned about). Keeping the selector string as
      // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
      // behavior — for config-array mode on every target, AND for declarative
      // mode on the five hostless targets, AND for Lit's config-array mode
      // (all of those cases have real shadow/light-DOM descendants the
      // selector correctly re-finds every time, exactly as before this task).
      // `$slotted.default.length` is a compile-time-constant `0` on the five
      // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
      // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
      // always-false runtime check, byte-behavior-identical to the pre-D4
      // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
      // there IS declarative (light-DOM) content assigned to the default slot,
      // does it switch to the explicit list — the phantom-slot problem
      // (`container.children` fallback catching the trailing empty <slot>) is
      // avoided the same way it always was: the selector filters to
      // `.rozie-embla__slide` elements only. `...$props.options` still
      // overrides `slides` last if a consumer needs to.
      slides: [].length > 0 ? [...this.viewportEl()!.nativeElement.querySelectorAll('.rozie-embla__slide'), ...[]] : '.rozie-embla__slide',
      loop: this.loop(),
      align: this.align(),
      axis: this.axis(),
      slidesToScroll: this.slidesToScroll(),
      dragFree: this.dragFree(),
      watchDrag: this.draggable(),
      containScroll: this.containScroll(),
      startIndex: this.startIndex(),
      skipSnaps: this.skipSnaps(),
      duration: this.duration(),
      direction: this.direction(),
      ...this.options()
    };
    return opts;
  };
  reinitOptions = () => {
    let opts: any = null;
    opts = this.initialOptions();
    delete opts.startIndex;
    return opts;
  };
  emblaPluginsFromProps = () => {
    const builtins = this.autoplay() ? [Autoplay({
      delay: this.autoplayDelay()
    })] : [];
    return [...builtins, ...this.plugins()];
  };
  thumbsOptionsFromProps = () => {
    let opts: any = null;
    opts = {
      containScroll: 'keepSnaps',
      dragFree: true,
      axis: this.axis()
    };
    return opts;
  };
  syncNav = () => {
    if (!this.embla) return;
    const i = this.embla.selectedScrollSnap();
    this.snaps.set(this.embla.scrollSnapList().map((_: any, n: any) => n));
    this.selected.set(i);
    this.canPrev.set(this.embla.canScrollPrev());
    this.canNext.set(this.embla.canScrollNext());
    if (this.emblaThumbs) this.emblaThumbs.scrollTo(i);
  };
  selectThumb = (i: any) => {
    this.scrollToIndex(i);
  };
  scrollNext = (jump?: any) => {
    if (this.embla) this.embla.scrollNext(jump);
  };
  scrollPrev = (jump?: any) => {
    if (this.embla) this.embla.scrollPrev(jump);
  };
  scrollToIndex = (index: any, jump?: any) => {
    if (this.embla) this.embla.scrollTo(index, jump);
  };
  reInitCarousel = (opts: any) => {
    if (this.embla) this.embla.reInit(opts ?? this.reinitOptions(), this.emblaPluginsFromProps());
  };
  canScrollNext = () => {
    return this.embla ? this.embla.canScrollNext() : false;
  };
  canScrollPrev = () => {
    return this.embla ? this.embla.canScrollPrev() : false;
  };
  getSelectedIndex = () => {
    return this.embla ? this.embla.selectedScrollSnap() : 0;
  };
  scrollSnapList = () => {
    return this.embla ? this.embla.scrollSnapList() : [];
  };
  scrollProgress = () => {
    return this.embla ? this.embla.scrollProgress() : 0;
  };
  slidesInView = () => {
    return this.embla ? this.embla.slidesInView() : [];
  };
  slidesNotInView = () => {
    return this.embla ? this.embla.slidesNotInView() : [];
  };
  previousScrollSnap = () => {
    return this.embla ? this.embla.previousScrollSnap() : 0;
  };
  getPlugins = () => {
    return this.embla ? this.embla.plugins() : null;
  };
  getInstance = () => {
    return this.embla;
  };

  private __rozieCvaOnChange: (v: number) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: number | null): void {
    this.selectedIndex.set(v ?? 0);
  }
  registerOnChange(fn: (v: number) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  static ngTemplateContextGuard(
    _dir: Carousel,
    _ctx: unknown,
  ): _ctx is SlideCtx | DefaultCtx | ThumbCtx {
    return true;
  }

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Carousel;
tsx
import type { JSX } from 'solid-js';
import { For, Show, children, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';

// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.

__rozieInjectStyle('Carousel-4143c216', `.rozie-embla[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
.rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow[data-rozie-s-4143c216] {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-embla-arrow-size, 2.25rem);
  height: var(--rozie-embla-arrow-size, 2.25rem);
  padding: 0;
  border: none;
  border-radius: var(--rozie-embla-arrow-radius, 50%);
  background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
  color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
  font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
  transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
.rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
.rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__arrow--next[data-rozie-s-4143c216] { right: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__dots[data-rozie-s-4143c216] {
  display: flex;
  justify-content: center;
  gap: var(--rozie-embla-dots-gap, 0.4rem);
  padding: var(--rozie-embla-dots-padding, 0.625rem 0);
}
.rozie-embla__dot[data-rozie-s-4143c216] {
  width: var(--rozie-embla-dot-size, 0.5rem);
  height: var(--rozie-embla-dot-size, 0.5rem);
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
.rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
  background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
  transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
}
.rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
.rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
.rozie-embla__thumb[data-rozie-s-4143c216] {
  flex: 0 0 auto;
  cursor: pointer;
  opacity: var(--rozie-embla-thumb-opacity, 0.5);
  border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
  border-radius: var(--rozie-embla-thumb-radius, 4px);
  overflow: hidden;
  transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
.rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
  opacity: var(--rozie-embla-thumb-selected-opacity, 1);
  border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
}`);

interface SlideSlotCtx { slide: any; index: any; }

interface ThumbSlotCtx { slide: any; index: any; }

interface CarouselProps {
  /**
   * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
   * @example
   * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
   */
  slides?: any[];
  /**
   * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
   */
  loop?: boolean;
  /**
   * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
   */
  align?: string;
  /**
   * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
   */
  axis?: string;
  /**
   * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
   */
  slidesToScroll?: number;
  /**
   * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
   */
  dragFree?: boolean;
  /**
   * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
   */
  draggable?: boolean;
  /**
   * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
   */
  containScroll?: string;
  /**
   * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
   */
  startIndex?: number;
  /**
   * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
   */
  skipSnaps?: boolean;
  /**
   * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
   */
  duration?: number;
  /**
   * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
   */
  direction?: string;
  /**
   * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
   */
  autoplay?: boolean;
  /**
   * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
   */
  autoplayDelay?: number;
  /**
   * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
   */
  dots?: boolean;
  /**
   * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
   */
  arrows?: boolean;
  /**
   * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
   */
  thumbnails?: boolean;
  /**
   * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
   */
  plugins?: any[];
  /**
   * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
   */
  options?: Record<string, any>;
  /**
   * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
   * @example
   * <Carousel :slides="items" r-model:selectedIndex="idx" />
   */
  selectedIndex?: number;
  defaultSelectedIndex?: number;
  onSelectedIndexChange?: (selectedIndex: number) => void;
  onSelect?: (...args: unknown[]) => void;
  onSettle?: (...args: unknown[]) => void;
  onReInit?: (...args: unknown[]) => void;
  onPointerDown?: (...args: unknown[]) => void;
  slideSlot?: (ctx: SlideSlotCtx) => JSX.Element;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  thumbSlot?: (ctx: ThumbSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: CarouselHandle) => void;
}

export interface CarouselHandle {
  scrollNext: (...args: any[]) => any;
  scrollPrev: (...args: any[]) => any;
  scrollToIndex: (...args: any[]) => any;
  reInitCarousel: (...args: any[]) => any;
  canScrollNext: (...args: any[]) => any;
  canScrollPrev: (...args: any[]) => any;
  getSelectedIndex: (...args: any[]) => any;
  scrollSnapList: (...args: any[]) => any;
  scrollProgress: (...args: any[]) => any;
  slidesInView: (...args: any[]) => any;
  slidesNotInView: (...args: any[]) => any;
  previousScrollSnap: (...args: any[]) => any;
  getPlugins: (...args: any[]) => any;
  getInstance: (...args: any[]) => any;
}

export default function Carousel(_props: CarouselProps): JSX.Element {
  const _merged = mergeProps({ slides: (() => [])() as any[], loop: false, align: 'center', axis: 'x', slidesToScroll: 1, dragFree: false, draggable: true, containScroll: 'trimSnaps', startIndex: 0, skipSnaps: false, duration: 25, direction: 'ltr', autoplay: false, autoplayDelay: 4000, dots: false, arrows: false, thumbnails: false, plugins: (() => [])() as any[], options: (() => ({}))() as Record<string, any> }, _props);
  const [local, attrs] = splitProps(_merged, ['slides', 'loop', 'align', 'axis', 'slidesToScroll', 'dragFree', 'draggable', 'containScroll', 'startIndex', 'skipSnaps', 'duration', 'direction', 'autoplay', 'autoplayDelay', 'dots', 'arrows', 'thumbnails', 'plugins', 'options', 'selectedIndex', 'children', 'ref', 'onSelect', 'onSettle', 'onReInit', 'onPointerDown']);
  const resolved = children(() => local.children);
  onMount(() => { local.ref?.({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance }); });

  const [selectedIndex, setSelectedIndex] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'selectedIndex', 0);
  const [snaps, setSnaps] = createSignal<any[]>([]);
  const [selected, setSelected] = createSignal(0);
  const [canPrev, setCanPrev] = createSignal(false);
  const [canNext, setCanNext] = createSignal(false);
  onMount(() => {
    const _cleanup = (() => {
    embla = EmblaCarousel(viewportElRef!, initialOptions(), emblaPluginsFromProps());

    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    if (local.thumbnails && thumbsViewportElRef) {
      emblaThumbs = EmblaCarousel(thumbsViewportElRef!, thumbsOptionsFromProps());
    }

    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    embla.on('select', () => {
      const i = embla.selectedScrollSnap();
      setSelectedIndex(i);
      _props.onSelect?.(i);
      syncNav();
    });
    embla.on('settle', () => _props.onSettle?.());
    embla.on('reInit', () => {
      _props.onReInit?.();
      syncNav();
    });
    embla.on('pointerDown', () => _props.onPointerDown?.());
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    embla.on('resize', () => syncNav());

    // seed the nav state immediately (covers the already-laid-out case)…
    syncNav();
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    if (typeof requestAnimationFrame === 'function') {
      const remeasure = () => {
        if (embla) embla.reInit(reinitOptions(), emblaPluginsFromProps());
      };
      remeasureRafOuter = requestAnimationFrame(() => {
        remeasureRafInner = requestAnimationFrame(remeasure);
      });
      remeasureTimer = setTimeout(remeasure, 0);
    }

    // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
    // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
    // fall through their existing `if (embla)` / ternary guards after unmount, so
    // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
    // symmetric — null before mount AND after unmount — instead of calling into a
    // destroyed engine.
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => {
    if (remeasureRafOuter) cancelAnimationFrame(remeasureRafOuter);
    if (remeasureRafInner) cancelAnimationFrame(remeasureRafInner);
    if (remeasureTimer) clearTimeout(remeasureTimer);
    remeasureRafOuter = null;
    remeasureRafInner = null;
    remeasureTimer = null;
    if (embla) {
      embla.destroy();
      embla = null;
    }
    if (emblaThumbs) {
      emblaThumbs.destroy();
      emblaThumbs = null;
    }
  });
  });
  createEffect(on(() => (() => selectedIndex())(), (v) => untrack(() => ((i: any) => {
    if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
  })(v)), { defer: true }));
  createEffect(on(() => (() => [local.loop, local.align, local.axis, local.slidesToScroll, local.dragFree, local.draggable, local.containScroll, local.skipSnaps, local.duration, local.direction].join('|'))(), (v) => untrack(() => (() => embla?.reInit(reinitOptions()))()), { defer: true }));
  createEffect(on(() => (() => `${local.autoplay}|${local.autoplayDelay}`)(), (v) => untrack(() => (() => embla?.reInit(reinitOptions(), emblaPluginsFromProps()))()), { defer: true }));
  createEffect(on(() => (() => local.slides.length)(), (v) => untrack(() => (() => {
    embla?.reInit(reinitOptions());
    emblaThumbs?.reInit(thumbsOptionsFromProps());
    syncNav();
  })()), { defer: true }));
  createEffect(on(() => (() => [].length)(), (v) => untrack(() => (() => {
    embla?.reInit(reinitOptions());
    syncNav();
  })()), { defer: true }));
  createEffect(on(() => (() => local.thumbnails)(), (v) => untrack(() => ((on: any) => {
    if (!on) {
      if (emblaThumbs) {
        emblaThumbs.destroy();
        emblaThumbs = null;
      }
      return;
    }
    if (emblaThumbs) return;
    // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
    // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
    // ViewportEl is still null when the callback runs, so a synchronous build
    // silently no-ops and a runtime thumbnails toggle never gets an engine.
    // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
    // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
    // lands first after the flush wins; targets that already flushed
    // synchronously take the immediate fast path in `build()` below and never
    // wait on the deferred passes.
    const build = () => {
      if (!embla) return; // unmounted — the $onMount cleanup nulled it
      if (!local.thumbnails) return; // toggled back off before this pass ran
      if (emblaThumbs) return; // idempotent across the double-schedule
      if (!thumbsViewportElRef) return;
      emblaThumbs = EmblaCarousel(thumbsViewportElRef, thumbsOptionsFromProps());
      syncNav();
    };
    build();
    if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
  })(v)), { defer: true }));
  let viewportElRef: HTMLElement | null = null;
  let thumbsViewportElRef: HTMLElement | null = null;

  // Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
  // useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
  let embla: any = null;
  // The SECOND Embla instance powering the optional synced thumbnail strip (null
  // when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
  let emblaThumbs: any = null;
  // D7: the two mount-time remeasure rAF handles + the macrotask handle, captured
  // so the mount cleanup can cancel them on unmount. Null-lets (not `= 0`) so they
  // type-neutralize to `any` — setTimeout's return type differs between the DOM
  // and Node lib shapes across the six leaves' tsconfigs.
  let remeasureRafOuter: any = null;
  let remeasureRafInner: any = null;
  let remeasureTimer: any = null;

  // Stable key for config-array slides — prefer an object id, fall back to value/index.
  function keyFor(slide: any, i: any) {
    if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
    return slide ?? i;
  }

  // Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
  // `...$props.options` escape hatch spreads last so a consumer can override anything.
  //
  // NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
  // options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
  // untyped `String` props are `string`, which does NOT structurally narrow to those
  // unions under strict tsc on the emitted leaves. Building the object into a
  // pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
  // engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
  // the same laundering discipline MapLibre uses for its untyped option object.
  function initialOptions() {
    let opts: any = null;
    opts = {
      // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
      // branch, an explicit element list only on the TRUE branch. This is
      // deliberately NOT an unconditional array — Embla's own `storeElements()`
      // re-resolves a STRING selector fresh via `container.querySelectorAll()`
      // on EVERY `reInit()` call, including its OWN internally-triggered ones
      // (the native `watchSlides: true` MutationObserver calls `reInit()` with
      // NO arguments, which reuses whatever `slides` value was last explicitly
      // set — a materialized ARRAY would freeze at that point, silently
      // breaking watchSlides' self-healing for ANY post-mount slide-count
      // change on ALL SIX targets, not just Lit; verified live via the VR
      // union matrix, not just reasoned about). Keeping the selector string as
      // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
      // behavior — for config-array mode on every target, AND for declarative
      // mode on the five hostless targets, AND for Lit's config-array mode
      // (all of those cases have real shadow/light-DOM descendants the
      // selector correctly re-finds every time, exactly as before this task).
      // `$slotted.default.length` is a compile-time-constant `0` on the five
      // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
      // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
      // always-false runtime check, byte-behavior-identical to the pre-D4
      // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
      // there IS declarative (light-DOM) content assigned to the default slot,
      // does it switch to the explicit list — the phantom-slot problem
      // (`container.children` fallback catching the trailing empty <slot>) is
      // avoided the same way it always was: the selector filters to
      // `.rozie-embla__slide` elements only. `...$props.options` still
      // overrides `slides` last if a consumer needs to.
      slides: [].length > 0 ? [...viewportElRef!.querySelectorAll('.rozie-embla__slide'), ...[]] : '.rozie-embla__slide',
      loop: local.loop,
      align: local.align,
      axis: local.axis,
      slidesToScroll: local.slidesToScroll,
      dragFree: local.dragFree,
      watchDrag: local.draggable,
      containScroll: local.containScroll,
      startIndex: local.startIndex,
      skipSnaps: local.skipSnaps,
      duration: local.duration,
      direction: local.direction,
      ...local.options
    };
    return opts;
  }

  // startIndex is INIT-ONLY. Embla's reActivate preserves the live position by
  // merging mergeOptions({ startIndex: selectedScrollSnap() }, withOptions) — and
  // withOptions WINS (embla-carousel@8 esm :1450, :1558). So any startIndex left
  // in a reInit payload teleports the carousel back to the prop's value on every
  // option flip, slide add/remove, and no-arg reInitCarousel(). Delete it AFTER
  // the ...$props.options spread so the raw escape hatch cannot reintroduce it
  // either. To move programmatically, use the scrollToIndex() handle verb.
  function reinitOptions() {
    let opts: any = null;
    opts = initialOptions();
    delete opts.startIndex;
    return opts;
  }

  // Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
  // any consumer-supplied plugins verbatim.
  function emblaPluginsFromProps() {
    const builtins = local.autoplay ? [Autoplay({
      delay: local.autoplayDelay
    })] : [];
    return [...builtins, ...local.plugins];
  }

  // Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
  // snap reachable + free dragging so the strip scrolls independently of the main
  // carousel, sharing the main axis. Built into a pre-nulled let for the same
  // literal-union laundering reason as initialOptions (axis is a `string`).
  function thumbsOptionsFromProps() {
    let opts: any = null;
    opts = {
      containScroll: 'keepSnaps',
      dragFree: true,
      axis: local.axis
    };
    return opts;
  }

  // Mirror the engine's live nav state into reactive $data so the built-in dots /
  // arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
  // scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
  // the thumbnail strip's scroll position in sync with the main selection.
  function syncNav() {
    if (!embla) return;
    const i = embla.selectedScrollSnap();
    setSnaps(embla.scrollSnapList().map((_: any, n: any) => n));
    setSelected(i);
    setCanPrev(embla.canScrollPrev());
    setCanNext(embla.canScrollNext());
    if (emblaThumbs) emblaThumbs.scrollTo(i);
  }

  // Thumb click → scroll the MAIN carousel. Calls the $expose'd scrollToIndex verb
  // directly (below) — arg-light internal calls to an exposed verb now typecheck
  // cleanly on all six targets: the emitter lowers a TRAILING $expose verb param
  // optional (`jump?: any` / `index`+`jump?`) whenever it sees a fewer-arg internal
  // call site (emitter-hardening backlog item #5). The prior raw-engine
  // navPrev/navNext/navTo bypass existed ONLY to dodge the pre-fix required-arg
  // TS2554 and is gone now that the compiler owns the arity.
  //
  // NB: no `clickAllowed()` drag-vs-click guard. Embla 8 dropped `clickAllowed`
  // from the public API entirely (it isn't a method on EmblaCarouselType), so the
  // old guard threw `TypeError: emblaThumbs.clickAllowed is not a function` on
  // every thumb tap. The modern Embla thumbs idiom calls `scrollTo` directly; a
  // drag that ends on a thumb simply scrolls, which is acceptable for a nav strip.
  function selectThumb(i: any) {
    scrollToIndex(i);
  }
  // ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
  // 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
  //  - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
  //    260802-tmo D1: a DELIBERATE behavior change to this published verb —
  //    no-arg reInitCarousel() now PRESERVES the current snap (via
  //    reinitOptions()) instead of resetting to `startIndex` on every call. Pass
  //    raw options to override. See docs/components/embla.md's handle table.
  //  - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
  //    setter collision on Lit/Angular; it's a method, the prop is the two-way value).
  //  - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
  //    bare `scrollTo` expose verb becomes a public method on the Lit custom-element
  //    class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
  //    `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
  //    resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
  //    method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
  //  - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
  //    React `setPlugins` auto-setter) — the get* getter convention. Returns the
  //    live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
  //  - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
  //    progress bars, lazy-load/in-view dots, and directional transitions — no
  //    matching prop, emit, or inherited DOM method — clear.
  //  - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
  function scrollNext(jump?: any) {
    if (embla) embla.scrollNext(jump);
  }
  function scrollPrev(jump?: any) {
    if (embla) embla.scrollPrev(jump);
  }
  function scrollToIndex(index: any, jump?: any) {
    if (embla) embla.scrollTo(index, jump);
  }
  function reInitCarousel(opts: any) {
    if (embla) embla.reInit(opts ?? reinitOptions(), emblaPluginsFromProps());
  }
  function canScrollNext() {
    return embla ? embla.canScrollNext() : false;
  }
  function canScrollPrev() {
    return embla ? embla.canScrollPrev() : false;
  }
  function getSelectedIndex() {
    return embla ? embla.selectedScrollSnap() : 0;
  }
  function scrollSnapList() {
    return embla ? embla.scrollSnapList() : [];
  }
  function scrollProgress() {
    return embla ? embla.scrollProgress() : 0;
  }
  function slidesInView() {
    return embla ? embla.slidesInView() : [];
  }
  function slidesNotInView() {
    return embla ? embla.slidesNotInView() : [];
  }
  function previousScrollSnap() {
    return embla ? embla.previousScrollSnap() : 0;
  }
  function getPlugins() {
    return embla ? embla.plugins() : null;
  }
  function getInstance() {
    return embla;
  }

  return (
    <>
    <div {...attrs} class={"rozie-embla" + " " + rozieClass({ 'rozie-embla--vertical': local.axis === 'y' }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-4143c216="">
      
      <div class={"rozie-embla__stage"} data-rozie-s-4143c216="">
        {<Show when={local.arrows}><button type="button" aria-label="Previous slide" class={"rozie-embla__arrow rozie-embla__arrow--prev"} disabled={!canPrev()} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { scrollPrev(); }} data-rozie-s-4143c216="">‹</button></Show>}<div class={"rozie-embla__viewport"} ref={(el) => { viewportElRef = el as HTMLElement; }} data-rozie-s-4143c216="">
          <div class={"rozie-embla__container"} data-rozie-s-4143c216="">
            
            <For each={local.slides}>{(slide, i) => <div class={"rozie-embla__slide"} data-rozie-s-4143c216="">
              {(_props.slideSlot ?? _props.slots?.['slide'])?.({ slide, index: i() }) ?? rozieDisplay(slide)}
            </div>}</For>
            
            {resolved()}
          </div>
        </div>
        {<Show when={local.arrows}><button type="button" aria-label="Next slide" class={"rozie-embla__arrow rozie-embla__arrow--next"} disabled={!canNext()} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { scrollNext(); }} data-rozie-s-4143c216="">›</button></Show>}</div>

      
      {<Show when={local.dots}><div class={"rozie-embla__dots"} data-rozie-s-4143c216="">
        <Key each={snaps() as readonly any[]} by={(di) => di}>{(di) => <button type="button" aria-label={rozieAttr('Go to slide ' + (di() + 1))} class={"rozie-embla__dot" + " " + rozieClass({ 'is-selected': di() === selected() })} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { scrollToIndex(di()); }} data-rozie-s-4143c216="" />}</Key>
      </div></Show>}{<Show when={local.thumbnails}><div class={"rozie-embla__thumbs"} data-rozie-s-4143c216="">
        <div class={"rozie-embla__thumbs-viewport"} ref={(el) => { thumbsViewportElRef = el as HTMLElement; }} data-rozie-s-4143c216="">
          <div class={"rozie-embla__thumbs-container"} data-rozie-s-4143c216="">
            <For each={local.slides}>{(item, i) => <div class={"rozie-embla__thumb" + " " + rozieClass({ 'is-selected': i() === selected() })} onClick={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { selectThumb(i()); }} data-rozie-s-4143c216="">
              {(_props.thumbSlot ?? _props.slots?.['thumb'])?.({ slide: item, index: i() }) ?? rozieDisplay(item)}
            </div>}</For>
          </div>
        </div>
      </div></Show>}</div>
    </>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { RozieSlotDistributor, createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';

// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.

interface RozieSlideSlotCtx {
  slide: any;
  index: any;
}

interface RozieThumbSlotCtx {
  slide: any;
  index: any;
}

@customElement('rozie-carousel')
export default class Carousel extends SignalWatcher(LitElement) {
  static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' };

  static styles = css`
:host{display:contents}
.rozie-embla[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
.rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow[data-rozie-s-4143c216] {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-embla-arrow-size, 2.25rem);
  height: var(--rozie-embla-arrow-size, 2.25rem);
  padding: 0;
  border: none;
  border-radius: var(--rozie-embla-arrow-radius, 50%);
  background: var(--rozie-embla-arrow-bg, rgb(255 255 255 / 0.9));
  color: var(--rozie-embla-arrow-fg, var(--rozie-embla-accent, #1a1a1a));
  font-size: var(--rozie-embla-arrow-font-size, 1.5rem);
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--rozie-embla-arrow-shadow, 0 1px 4px rgb(0 0 0 / 0.25));
  transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-arrow-hover-bg, #fff); }
.rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: var(--rozie-embla-arrow-disabled-opacity, 0.35); cursor: default; }
.rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__arrow--next[data-rozie-s-4143c216] { right: var(--rozie-embla-arrow-inset, 0.5rem); }
.rozie-embla__dots[data-rozie-s-4143c216] {
  display: flex;
  justify-content: center;
  gap: var(--rozie-embla-dots-gap, 0.4rem);
  padding: var(--rozie-embla-dots-padding, 0.625rem 0);
}
.rozie-embla__dot[data-rozie-s-4143c216] {
  width: var(--rozie-embla-dot-size, 0.5rem);
  height: var(--rozie-embla-dot-size, 0.5rem);
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--rozie-embla-dot-bg, rgb(0 0 0 / 0.25));
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot[data-rozie-s-4143c216]:hover { background: var(--rozie-embla-dot-hover-bg, rgba(0, 0, 0, 0.45)); }
.rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
  background: var(--rozie-embla-dot-selected-bg, var(--rozie-embla-accent, #1a1a1a));
  transform: scale(var(--rozie-embla-dot-selected-scale, 1.25));
}
.rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: var(--rozie-embla-thumbs-gap, 0.5rem); }
.rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: var(--rozie-embla-thumb-gap, 0.5rem); }
.rozie-embla__thumb[data-rozie-s-4143c216] {
  flex: 0 0 auto;
  cursor: pointer;
  opacity: var(--rozie-embla-thumb-opacity, 0.5);
  border: var(--rozie-embla-thumb-border-width, 2px) solid var(--rozie-embla-thumb-border-color, transparent);
  border-radius: var(--rozie-embla-thumb-radius, 4px);
  overflow: hidden;
  transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: var(--rozie-embla-thumb-hover-opacity, 0.8); }
.rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
  opacity: var(--rozie-embla-thumb-selected-opacity, 1);
  border-color: var(--rozie-embla-thumb-selected-border-color, var(--rozie-embla-accent, #1a1a1a));
}
`;

  /**
   * Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
   * @example
   * <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
   */
  @property({ type: Array }) slides: any[] = [];
  /**
   * Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
   */
  @property({ type: Boolean, reflect: true }) loop: boolean = false;
  /**
   * Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
   */
  @property({ type: String, reflect: true }) align: string = 'center';
  /**
   * Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
   */
  @property({ type: String, reflect: true }) axis: string = 'x';
  /**
   * Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
   */
  @property({ type: Number, reflect: true }) slidesToScroll: number = 1;
  /**
   * Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
   */
  @property({ type: Boolean, reflect: true }) dragFree: boolean = false;
  /**
   * Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
   */
  @property({ type: Boolean, reflect: true }) draggable: boolean = true;
  /**
   * Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
   */
  @property({ type: String, reflect: true }) containScroll: string = 'trimSnaps';
  /**
   * Initial snap index the carousel starts at (the Embla `startIndex` option). Init-only — to move after mount use the `scrollToIndex()` handle verb or the `selectedIndex` model.
   */
  @property({ type: Number, reflect: true }) startIndex: number = 0;
  /**
   * Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
   */
  @property({ type: Boolean, reflect: true }) skipSnaps: boolean = false;
  /**
   * Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
   */
  @property({ type: Number, reflect: true }) duration: number = 25;
  /**
   * Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
   */
  @property({ type: String, reflect: true }) direction: string = 'ltr';
  /**
   * Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
   */
  @property({ type: Boolean, reflect: true }) autoplay: boolean = false;
  /**
   * Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
   */
  @property({ type: Number, reflect: true }) autoplayDelay: number = 4000;
  /**
   * Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
   */
  @property({ type: Boolean, reflect: true }) dots: boolean = false;
  /**
   * Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
   */
  @property({ type: Boolean, reflect: true }) arrows: boolean = false;
  /**
   * Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
   */
  @property({ type: Boolean, reflect: true }) thumbnails: boolean = false;
  /**
   * Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
   */
  @property({ type: Array }) plugins: any[] = [];
  /**
   * Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
   */
  @property({ type: Object }) options: any = {};
  /**
   * The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
   * @example
   * <Carousel :slides="items" r-model:selectedIndex="idx" />
   */
  @property({ type: Number, attribute: 'selected-index' }) _selectedIndex_attr: number = 0;
  private _selectedIndexControllable = createLitControllableProperty<number>({ host: this, eventName: 'selected-index-change', defaultValue: 0, initialControlledValue: undefined });
  private _snaps = signal<any[]>([]);
  private _selected = signal(0);
  private _canPrev = signal(false);
  private _canNext = signal(false);
  @query('[data-rozie-ref="viewportEl"]') private _refViewportEl!: HTMLElement;
  @query('[data-rozie-ref="thumbsViewportEl"]') private _refThumbsViewportEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchPrev_3: unknown;
private __rozieWatchInitial_4 = true;
private __rozieFirstUpdateDone = false;

  private _rozieSlotDistributor = new RozieSlotDistributor(this);

  @state() private _hasSlotSlide = false;
  @queryAssignedElements({ slot: 'slide', flatten: true }) private _slotSlideElements!: Element[];
  @property({ attribute: false }) slide?: (scope: { slide: any; index: any }) => unknown;
  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
  private _slotDefaultAssigned = signal<Element[]>([]);
  @state() private _hasSlotThumb = false;
  @queryAssignedElements({ slot: 'thumb', flatten: true }) private _slotThumbElements!: Element[];
  @property({ attribute: false }) thumb?: (scope: { slide: any; index: 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="slide"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotSlide = this._slotSlideElements.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:not([name])');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => {
          this._hasSlotDefault = this._slotDefaultElements.length > 0;
          const assigned = this._slotDefaultElements;
          const prev = this._slotDefaultAssigned.value;
          if (assigned.length !== prev.length || assigned.some((el, i) => el !== prev[i])) {
            this._slotDefaultAssigned.value = assigned;
          }
        };
        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="thumb"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotThumb = this._slotThumbElements.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._hasSlotSlide = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slide');
    this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    this._slotDefaultAssigned.value = Array.from(this.children).filter((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    this._hasSlotThumb = Array.from(this.children).some((el) => el.getAttribute('slot') === 'thumb');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();

    this._disconnectCleanups.push((() => {
      if (this.remeasureRafOuter) cancelAnimationFrame(this.remeasureRafOuter);
      if (this.remeasureRafInner) cancelAnimationFrame(this.remeasureRafInner);
      if (this.remeasureTimer) clearTimeout(this.remeasureTimer);
      this.remeasureRafOuter = null;
      this.remeasureRafInner = null;
      this.remeasureTimer = null;
      if (this.embla) {
        this.embla.destroy();
        this.embla = null;
      }
      if (this.emblaThumbs) {
        this.emblaThumbs.destroy();
        this.emblaThumbs = null;
      }
    }));

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.selectedIndex)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((i: any) => {
      if (this.embla && typeof i === 'number' && i !== this.embla.selectedScrollSnap()) this.embla.scrollTo(i);
    })(__watchVal); }); }));
    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._slotDefaultAssigned.value.length)(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } (() => {
      this.embla?.reInit(this.reinitOptions());
      this.syncNav();
    })(); }); }));

    this.embla = EmblaCarousel(this._refViewportEl, this.initialOptions(), this.emblaPluginsFromProps());

    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    // Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
    // exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
    // only $refs-safe site). Stays null otherwise (zero overhead).
    if (this.thumbnails && this._refThumbsViewportEl) {
      this.emblaThumbs = EmblaCarousel(this._refThumbsViewportEl, this.thumbsOptionsFromProps());
    }

    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    // engine → consumer: on every snap change write the two-way model AND fire the
    // distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
    // refreshes the built-in dots/arrows + thumb sync.
    this.embla.on('select', () => {
      const i = this.embla.selectedScrollSnap();
      this._selectedIndexControllable.write(i);
      this.dispatchEvent(new CustomEvent("select", {
        detail: i,
        bubbles: true,
        composed: true
      }));
      this.syncNav();
    });
    this.embla.on('settle', () => this.dispatchEvent(new CustomEvent("settle", {
      detail: undefined,
      bubbles: true,
      composed: true
    })));
    this.embla.on('reInit', () => {
      this.dispatchEvent(new CustomEvent("re-init", {
        detail: undefined,
        bubbles: true,
        composed: true
      }));
      this.syncNav();
    });
    this.embla.on('pointerDown', () => this.dispatchEvent(new CustomEvent("pointer-down", {
      detail: undefined,
      bubbles: true,
      composed: true
    })));
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    // Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
    // attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
    // at init is stale — and a slide-size change (vs a viewport resize or slide
    // add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
    // its own. Re-measure once after the first layout flush via reInit (its `reInit`
    // handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
    this.embla.on('resize', () => this.syncNav());

    // seed the nav state immediately (covers the already-laid-out case)…
    // seed the nav state immediately (covers the already-laid-out case)…
    this.syncNav();
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    // …then re-measure after layout fully settles (a consumer's slide CSS / a root
    // width via attribute fallthrough can land a couple of frames after $onMount;
    // Embla caches slide sizes at init and a slide-size change alone fires no
    // re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
    // the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
    if (typeof requestAnimationFrame === 'function') {
      const remeasure = () => {
        if (this.embla) this.embla.reInit(this.reinitOptions(), this.emblaPluginsFromProps());
      };
      this.remeasureRafOuter = requestAnimationFrame(() => {
        this.remeasureRafInner = requestAnimationFrame(remeasure);
      });
      this.remeasureTimer = setTimeout(remeasure, 0);
    }

    // D7: cancel every scheduled handle AND null both engines on unmount. Nulling
    // both (not just calling destroy()) makes all 14 exposed verbs + getInstance()
    // fall through their existing `if (embla)` / ternary guards after unmount, so
    // the handle-manifest's "Null / 0 / Empty before mount" contract becomes
    // symmetric — null before mount AND after unmount — instead of calling into a
    // destroyed engine.
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (this.__rozieFirstUpdateDone && (changedProperties.has('loop') || changedProperties.has('align') || changedProperties.has('axis') || changedProperties.has('slidesToScroll') || changedProperties.has('dragFree') || changedProperties.has('draggable') || changedProperties.has('containScroll') || changedProperties.has('skipSnaps') || changedProperties.has('duration') || changedProperties.has('direction'))) { const __watchVal = (() => [this.loop, this.align, this.axis, this.slidesToScroll, this.dragFree, this.draggable, this.containScroll, this.skipSnaps, this.duration, this.direction].join('|'))(); (() => this.embla?.reInit(this.reinitOptions()))(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('autoplay') || changedProperties.has('autoplayDelay'))) { const __watchVal = (() => `${this.autoplay}|${this.autoplayDelay}`)(); (() => this.embla?.reInit(this.reinitOptions(), this.emblaPluginsFromProps()))(); }
    if (changedProperties.has('slides')) { const __watchVal = (() => this.slides.length)(); if (!this.__rozieFirstUpdateDone) { this.__rozieWatchPrev_3 = __watchVal; } else if (!Object.is(__watchVal, this.__rozieWatchPrev_3)) { this.__rozieWatchPrev_3 = __watchVal; (() => {
      this.embla?.reInit(this.reinitOptions());
      this.emblaThumbs?.reInit(this.thumbsOptionsFromProps());
      this.syncNav();
    })(); } }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('thumbnails'))) { const __watchVal = (() => this.thumbnails)(); ((on: any) => {
      if (!on) {
        if (this.emblaThumbs) {
          this.emblaThumbs.destroy();
          this.emblaThumbs = null;
        }
        return;
      }
      if (this.emblaThumbs) return;
      // The r-if'd thumbs viewport mounts in the SAME tick this watch fires, and a
      // pre-flush watcher (Vue's default) runs BEFORE that render — $refs.thumbs-
      // ViewportEl is still null when the callback runs, so a synchronous build
      // silently no-ops and a runtime thumbnails toggle never gets an engine.
      // Double-schedule an idempotent pass through queueMicrotask AND rAF (the
      // DatePicker scheduleFocus idiom, DatePicker.rozie:656-673) so whichever
      // lands first after the flush wins; targets that already flushed
      // synchronously take the immediate fast path in `build()` below and never
      // wait on the deferred passes.
      const build = () => {
        if (!this.embla) return; // unmounted — the $onMount cleanup nulled it
        if (!this.thumbnails) return; // toggled back off before this pass ran
        if (this.emblaThumbs) return; // idempotent across the double-schedule
        if (!this._refThumbsViewportEl) return;
        this.emblaThumbs = EmblaCarousel(this._refThumbsViewportEl, this.thumbsOptionsFromProps());
        this.syncNav();
      };
      build();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(build);
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(build);
    })(__watchVal); }
    this.__rozieFirstUpdateDone = true;
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      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 === 'selected-index') this._selectedIndexControllable.notifyAttributeChange(value === null ? 0 : Number(value));
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-embla": true, 'rozie-embla--vertical': this.axis === 'y' }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-4143c216>
  
  <div class="rozie-embla__stage" data-rozie-s-4143c216>
    ${this.arrows ? html`<button class="rozie-embla__arrow rozie-embla__arrow--prev" type="button" ?disabled=${!this._canPrev.value} aria-label="Previous slide" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.scrollPrev(); }} data-rozie-s-4143c216>‹</button>` : nothing}<div class="rozie-embla__viewport" data-rozie-ref="viewportEl" data-rozie-s-4143c216>
      <div class="rozie-embla__container" data-rozie-s-4143c216>
        
        ${repeat<any>(this.slides, (slide, i) => this.keyFor(slide, i), (slide, i) => html`<div class="rozie-embla__slide" data-rozie-s-4143c216>
          ${this.slide !== undefined ? this.slide({slide: slide, index: i}) : html`<slot name="slide" data-rozie-params=${(() => { try { return JSON.stringify({slide: slide, index: i}); } catch { return '{}'; } })()}>${rozieDisplay(slide)}</slot>`}
        </div>`)}
        
        <slot></slot>
      </div>
    </div>
    ${this.arrows ? html`<button class="rozie-embla__arrow rozie-embla__arrow--next" type="button" ?disabled=${!this._canNext.value} aria-label="Next slide" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.scrollNext(); }} data-rozie-s-4143c216>›</button>` : nothing}</div>

  
  ${this.dots ? html`<div class="rozie-embla__dots" data-rozie-s-4143c216>
    ${repeat<any>(this._snaps.value, (di, _idx) => di, (di, _idx) => html`<button class="${Object.entries({ "rozie-embla__dot": true, 'is-selected': di === this._selected.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label=${rozieAttr('Go to slide ' + (di + 1))} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.scrollToIndex(di); }} data-rozie-s-4143c216></button>`)}
  </div>` : nothing}${this.thumbnails ? html`<div class="rozie-embla__thumbs" data-rozie-s-4143c216>
    <div class="rozie-embla__thumbs-viewport" data-rozie-ref="thumbsViewportEl" data-rozie-s-4143c216>
      <div class="rozie-embla__thumbs-container" data-rozie-s-4143c216>
        ${repeat<any>(this.slides, (item, i) => this.keyFor(item, i), (item, i) => html`<div class="${Object.entries({ "rozie-embla__thumb": true, 'is-selected': i === this._selected.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" @click=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.selectThumb(i); }} data-rozie-s-4143c216>
          ${this.thumb !== undefined ? this.thumb({slide: item, index: i}) : html`<slot name="thumb" data-rozie-params=${(() => { try { return JSON.stringify({slide: item, index: i}); } catch { return '{}'; } })()}>${rozieDisplay(item)}</slot>`}
        </div>`)}
      </div>
    </div>
  </div>` : nothing}</div>
`;
  }

  embla: any = null;

  emblaThumbs: any = null;

  remeasureRafOuter: any = null;

  remeasureRafInner: any = null;

  remeasureTimer: any = null;

  keyFor = (slide: any, i: any) => {
  if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
  return slide ?? i;
};

  initialOptions = () => {
  let opts: any = null;
  opts = {
    // quick 260807-cor (D4) — CONDITIONAL: the selector string on the FALSE
    // branch, an explicit element list only on the TRUE branch. This is
    // deliberately NOT an unconditional array — Embla's own `storeElements()`
    // re-resolves a STRING selector fresh via `container.querySelectorAll()`
    // on EVERY `reInit()` call, including its OWN internally-triggered ones
    // (the native `watchSlides: true` MutationObserver calls `reInit()` with
    // NO arguments, which reuses whatever `slides` value was last explicitly
    // set — a materialized ARRAY would freeze at that point, silently
    // breaking watchSlides' self-healing for ANY post-mount slide-count
    // change on ALL SIX targets, not just Lit; verified live via the VR
    // union matrix, not just reasoned about). Keeping the selector string as
    // the DEFAULT branch preserves 100% of the pre-D4 native-self-healing
    // behavior — for config-array mode on every target, AND for declarative
    // mode on the five hostless targets, AND for Lit's config-array mode
    // (all of those cases have real shadow/light-DOM descendants the
    // selector correctly re-finds every time, exactly as before this task).
    // `$slotted.default.length` is a compile-time-constant `0` on the five
    // hostless targets (Task 1 lowers `$slotted.default` to `[]`), so this
    // ternary is UNCONDITIONALLY the selector-string branch there — a cheap,
    // always-false runtime check, byte-behavior-identical to the pre-D4
    // `slides: '.rozie-embla__slide'` line. Only on Lit, and only when
    // there IS declarative (light-DOM) content assigned to the default slot,
    // does it switch to the explicit list — the phantom-slot problem
    // (`container.children` fallback catching the trailing empty <slot>) is
    // avoided the same way it always was: the selector filters to
    // `.rozie-embla__slide` elements only. `...$props.options` still
    // overrides `slides` last if a consumer needs to.
    slides: this._slotDefaultAssigned.value.length > 0 ? [...this._refViewportEl.querySelectorAll('.rozie-embla__slide'), ...this._slotDefaultAssigned.value] : '.rozie-embla__slide',
    loop: this.loop,
    align: this.align,
    axis: this.axis,
    slidesToScroll: this.slidesToScroll,
    dragFree: this.dragFree,
    watchDrag: this.draggable,
    containScroll: this.containScroll,
    startIndex: this.startIndex,
    skipSnaps: this.skipSnaps,
    duration: this.duration,
    direction: this.direction,
    ...this.options
  };
  return opts;
};

  reinitOptions = () => {
  let opts: any = null;
  opts = this.initialOptions();
  delete opts.startIndex;
  return opts;
};

  emblaPluginsFromProps = () => {
  const builtins = this.autoplay ? [Autoplay({
    delay: this.autoplayDelay
  })] : [];
  return [...builtins, ...this.plugins];
};

  thumbsOptionsFromProps = () => {
  let opts: any = null;
  opts = {
    containScroll: 'keepSnaps',
    dragFree: true,
    axis: this.axis
  };
  return opts;
};

  syncNav = () => {
  if (!this.embla) return;
  const i = this.embla.selectedScrollSnap();
  this._snaps.value = this.embla.scrollSnapList().map((_: any, n: any) => n);
  this._selected.value = i;
  this._canPrev.value = this.embla.canScrollPrev();
  this._canNext.value = this.embla.canScrollNext();
  if (this.emblaThumbs) this.emblaThumbs.scrollTo(i);
};

  selectThumb = (i: any) => {
  this.scrollToIndex(i);
};

  scrollNext(jump?: any) {
    if (this.embla) this.embla.scrollNext(jump);
  }

  scrollPrev(jump?: any) {
    if (this.embla) this.embla.scrollPrev(jump);
  }

  scrollToIndex(index: any, jump?: any) {
    if (this.embla) this.embla.scrollTo(index, jump);
  }

  reInitCarousel(opts: any) {
    if (this.embla) this.embla.reInit(opts ?? this.reinitOptions(), this.emblaPluginsFromProps());
  }

  canScrollNext() {
    return this.embla ? this.embla.canScrollNext() : false;
  }

  canScrollPrev() {
    return this.embla ? this.embla.canScrollPrev() : false;
  }

  getSelectedIndex() {
    return this.embla ? this.embla.selectedScrollSnap() : 0;
  }

  scrollSnapList() {
    return this.embla ? this.embla.scrollSnapList() : [];
  }

  scrollProgress() {
    return this.embla ? this.embla.scrollProgress() : 0;
  }

  slidesInView() {
    return this.embla ? this.embla.slidesInView() : [];
  }

  slidesNotInView() {
    return this.embla ? this.embla.slidesNotInView() : [];
  }

  previousScrollSnap() {
    return this.embla ? this.embla.previousScrollSnap() : 0;
  }

  getPlugins() {
    return this.embla ? this.embla.plugins() : null;
  }

  getInstance() {
    return this.embla;
  }

  get selectedIndex(): number { return this._selectedIndexControllable.read(); }
  set selectedIndex(v: number) { this._selectedIndexControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref', 'slides', 'loop', 'align', 'axis', 'slides-to-scroll', 'slidestoscroll', 'drag-free', 'dragfree', 'draggable', 'contain-scroll', 'containscroll', 'start-index', 'startindex', 'skip-snaps', 'skipsnaps', 'duration', 'direction', 'autoplay', 'autoplay-delay', 'autoplaydelay', 'dots', 'arrows', 'thumbnails', 'plugins', 'options', 'selected-index', 'selectedindex']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

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 two-way selectedIndex, same imperative handle, identical on every target.

See also

Pre-1.0 — APIs may change between minor versions.