Skip to content

CodeMirror — live demo

This is the real @rozie-ui/codemirror-vue package running on this page (VitePress is itself a Vue app). Type in the editor below, toggle the language and theme, then Get value to read the live document straight off the imperative handle. Everything here is driven by the same CodeMirror.rozie source that compiles to all six frameworks.

The document is two-way bound with v-model:value — the readout above updates live as you type, the language/theme toggles reconfigure the live editor without a remount (cursor, history, and scroll position are preserved), and the buttons drive the imperative handle (focus, insertText, getValue). The JSON/Python toggles feed the curated @rozie-ui/codemirror-vue/languages presets through :extensions, since the bundled language prop ships JavaScript only. See the full API for the complete prop/handle/slot surface.

One source, six outputs

You author the component once as a .rozie file:

html
<!--
  CodeMirror.rozie — data-bound port of CodeMirror 6.

  CodeMirror 6 is the de-facto modular code editor for the web. Every
  framework has a wrapper (react-codemirror2 / vue-codemirror / svelte-
  codemirror / ngx-codemirror) that shuttles a `value` prop through the
  EditorView/EditorState API and forwards change events out. Four
  maintenance burdens, ONE Rozie source.

  Why this is a worthwhile demo:
    - Two-way binding (`r-model:value`) through a non-input
      contenteditable engine. Most existing model:true examples in the
      Rozie suite are form-shaped wrappers around `<input>`/`<select>`.
      CodeMirror is the archetypal engine-mediated case: edits flow back
      via an `updateListener` extension, not a DOM input event.
    - Live reconfiguration via Compartments. language / theme / readOnly /
      placeholder / extensions / panel changes `dispatch` reconfiguration
      effects without re-mounting the editor, mirroring the Chart.js
      `chart.update()` runtime path.
    - Echo-loop guard. Model two-way bindings can ping-pong when the
      consumer's $data signals back into the wrapper's $watch faster
      than the wrapper's emit clears. Solve once here, every consumer
      gets it.
    - Consumer-extensible `extensions` passthrough. CodeMirror 6 has no
      large "options bag" — everything is an Extension. The `extensions`
      prop is composed LAST so consumer extensions win CM6's
      last-registered-wins facets.
    - Five injection portal slots — proving CM6's extension-mounted
      injection across the mount-once, reactive, AND reactive-multi-instance
      portal primitives:
        · `panel`    — bottom-docked `showPanel` facet (mount-once).
        · `topPanel` — top-docked `showPanel` facet (mount-once, G5 wave 1).
        · `tooltip`  — cursor-anchored `showTooltip` facet (REACTIVE, G5
          wave 1; CodeMirror's first reactive slot — the consumer fragment
          mounts once and re-renders in place as the caret moves).
        · `gutter`   — per-line markers via a custom `gutter()` (REACTIVE
          MULTI-INSTANCE, G5 wave 2; ONE portal handle per visible marker —
          the TipTap nodeView template). Driven by the `gutterLines` prop.
        · `decoration` — inline widgets at document positions via a
          `Decoration.widget` set (REACTIVE MULTI-INSTANCE, G5 wave 2; one
          portal handle per visible widget). Driven by the `decorations` prop.

  Consumer example:

    <CodeMirror r-model:value="$data.source" language="javascript" theme="dark" />
-->

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

<props>
{
  value: {
    type: String,
    default: '',
    model: true,
    docs: {
      description:
        'The two-way document text (`r-model:value`) — the editor\'s contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror\'s `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.',
      example: '<CodeMirror r-model:value="source" language="javascript" theme="dark" />',
    },
  },
  language: {
    type: String,
    default: 'javascript',
    docs: {
      description:
        'Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.',
    },
  },
  // theme accepts the two built-in strings ('light' → no theme; 'dark' →
  // oneDark) OR, as of G3, a CodeMirror Extension / Extension[] passed straight
  // through. A non-string theme is composed via the live themeCompartment so it
  // reconfigures with no remount, same as the string forms. The union type is
  // declared permissively (no `type:` constraint) so the `<props>` parser accepts
  // both a string AND an Extension object/array.
  theme: {
    default: 'light',
    docs: {
      description:
        'Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.',
    },
  },
  readOnly: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).',
    },
  },
  height: {
    type: Number,
    default: 240,
    docs: {
      description: 'Editor height in pixels, applied to the wrapper\'s host box.',
    },
  },
  placeholder: {
    type: String,
    default: '',
    docs: {
      description:
        'Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.',
    },
  },
  extensions: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror\'s last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).',
    },
  },
  // basicSetup (G1) — when true, swap the thin manual baseline (lineNumbers +
  // history + default/history keymaps) for CodeMirror 6's `basicSetup` bundle
  // (autocomplete, search, bracket matching, code folding, lint gutter, richer
  // keymaps). Read ONCE at buildState time — it is a large construction-time
  // extension bundle with NO compartment, so toggling it at runtime requires a
  // re-mount (mirrors the SortableJS construction-time-knob remount note).
  basicSetup: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6\'s batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.',
    },
  },
  // gutterLines (G5 wave 2) — 1-based line numbers that each get a custom
  // gutter marker rendered by the `gutter` REACTIVE MULTI-INSTANCE portal slot
  // (one portal handle per visible marker). Out-of-range lines are ignored.
  // Runtime-updatable via a `gutterCompartment` reconfigure (no remount).
  gutterLines: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.',
    },
  },
  // decorations (G5 wave 2) — array of `{ from, to? }` 0-based document offsets
  // that each get an inline widget rendered by the `decoration` REACTIVE
  // MULTI-INSTANCE portal slot (one portal handle per visible widget). A point
  // widget is placed at `from`; `to` is passed through in scope for the
  // consumer's awareness. Consumers can compute an offset from a line via
  // `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment`
  // reconfigure (no remount).
  decorations: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer\'s awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.',
    },
  },
}
</props>

<script>
import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state'
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror'

let view = null

// CodeMirror's updateListener fires on EVERY transaction, including our own
// $watch-driven dispatch when the consumer changes `value`. Without a guard
// the wrapper would emit its own dispatch back through the model path on
// the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
let suppressEmit = false

// Compartments let us swap individual extensions at runtime via
// `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
// rebuilding the entire EditorState. Each runtime-updatable prop gets one so
// prop changes don't lose cursor/history/scroll position.
const langCompartment = new Compartment()
const themeCompartment = new Compartment()
const readOnlyCompartment = new Compartment()
const placeholderCompartment = new Compartment()
const extensionsCompartment = new Compartment()
const panelCompartment = new Compartment()
// topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
// slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
const topPanelCompartment = new Compartment()
// gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
// one portal handle per visible marker/widget (the TipTap nodeView template).
// Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
// reconfigures the marked lines / decorated ranges LIVE with no remount, like
// every other runtime-updatable prop. The GutterMarker/WidgetType classes that
// capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
// top-level $portals reference fails the bundled-leaf strict typecheck — the
// panel/tooltip/nodeView discipline), so these compartments are filled from
// factories invoked in the mount body.
const gutterCompartment = new Compartment()
const decorationCompartment = new Compartment()
// The gutter / decoration extension FACTORIES capture the per-target $portals
// helper, so they MUST be built inside $onMount (a top-level $portals reference
// fails the bundled-leaf strict typecheck). But the gutterLines / decorations
// $watch reconfigures are top-level and need to rebuild the extension on prop
// change. Bridge with these component-scope `let`s: $onMount assigns each to its
// mount-built factory; the $watch closures call through them (no-op before mount
// or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
// top-level $watch can reach them — the same hoist the TipTap toolbarDispose
// uses for a mount-built handle referenced from outside the mount body.
let rebuildGutterExt = null
let rebuildDecorationExt = null
// tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
// cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
// (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
// portal handle re-renders the consumer fragment IN PLACE on caret move rather
// than remounting it each keystroke. NO compartment — a StateField is the
// idiomatic showTooltip source and there is no runtime prop to reconfigure it
// against (slot presence is decided once at mount).

// language is a convenience prop mapping to the ONE bundled language
// (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
// highlighting); consumers add other languages via :extensions (D-03). This
// FIXES the prior declared-but-ignored bug where buildState hard-coded
// javascript() regardless of $props.language.
const langExt = () => $props.language === 'javascript' ? javascript() : []

// theme resolution (G3): the two built-in strings map to oneDark / [];
// anything else is treated as a CM Extension (or Extension[]) and passed
// straight through the themeCompartment. The $watch(theme) reconfigure below
// covers extension themes live, identical to the string forms.
const themeExt = () => {
  const t = $props.theme
  if (t === 'dark') return oneDark
  if (t === 'light' || t === '' || t == null) return []
  // t is a CM Extension / Extension[] passthrough by this branch (the widened
  // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
  // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
  // accept it; the type-neutral targets strip types entirely.
  return t
}

// placeholder ext only when a non-empty placeholder string is supplied.
const phExt = () => $props.placeholder ? placeholderExt($props.placeholder) : []

// baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
// `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
// lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
// the manual trio would double those up). When off, keep the exact thin
// baseline the wrapper shipped before G1 (line numbers + history + default/
// history keymaps) → existing consumers stay byte-stable. Read at buildState
// time only — no compartment (see the basicSetup prop note).
const baselineExt = () => $props.basicSetup
  ? [basicSetupBundle]
  : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])]

// buildState + the panel-slot wiring live INSIDE $onMount so the $portals.panel
// reference is bound in the mount scope. The per-target emitters scope the
// concrete portal helper inside the mount lifecycle (React useEffect / Lit
// firstUpdated / etc.); a top-level `panelExt` that references $portals would
// land out-of-scope of that helper and fail the bundled-leaf strict typecheck
// (TS2304 'portals' / TS2742). Keeping the $portals.panel use inside $onMount
// mirrors FullCalendar's portal pattern (its eventContent callbacks reference
// $portals only inside $onMount). buildState is only ever called here, so this
// is a behavior-preserving relocation. Compartments + langExt/themeExt/phExt
// stay top-level — the $watch reconfigures still reference them.
$onMount(() => {
  // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
  // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
  // consumer's framework-native fragment on Panel.mount() and the returned
  // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
  // doesn't fill the slot.
  // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
  // `mount() {}` methods) and the panel host element + view are captured in
  // plain `const`s. The object-method form gives each method its own `this`
  // scope, and the Lit emitter's component-field rewrite walks INTO that method
  // body and rewrites a closure-captured `view` reference to `this.view`
  // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
  // properties share the enclosing lexical scope, so the captured `panelView`
  // const resolves correctly on every target. CM6 calls `panel.mount()` /
  // `panel.destroy()` either way.
  const panelExt = () => {
    if (!$slots.panel) return []
    return showPanel.of((panelView) => {
      const dom = document.createElement('div')
      dom.className = 'rozie-cm-panel'
      const scope = { view: panelView }
      let dispose = null
      return {
        dom,
        top: false,
        mount: () => { dispose = $portals.panel(dom, scope) },
        destroy: () => { dispose?.(); dispose = null },
      }
    })
  }

  // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
  // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
  // object-method `mount() {}` — the Lit field-rewrite caveat documented on
  // panelExt above applies identically), differing ONLY in `top: true` and the
  // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
  const topPanelExt = () => {
    if (!$slots.topPanel) return []
    return showPanel.of((panelView) => {
      const dom = document.createElement('div')
      dom.className = 'rozie-cm-panel-top'
      const scope = { view: panelView }
      let dispose = null
      return {
        dom,
        top: true,
        mount: () => { dispose = $portals.topPanel(dom, scope) },
        destroy: () => { dispose?.(); dispose = null },
      }
    })
  }

  // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
  // cursor-anchored tooltip provided through the `showTooltip` facet via a
  // StateField that yields ONE Tooltip at the main selection head whenever the
  // `tooltip` slot is filled.
  //
  // UPDATE-IN-PLACE reconciliation (verified against the installed
  // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
  // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
  // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
  // old one's (`other.create == tip.create`); and when the whole showTooltip
  // facet INPUT is unchanged it skips matching entirely and calls update() on
  // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
  // (`stableTooltip`, stable `create`) and returning that SAME object from the
  // field's `update` while the head only moved. So the consumer fragment mounts
  // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
  // every caret move flows through TooltipView.update → handle.update(scope) —
  // re-rendering the fragment IN PLACE, never remounting it.
  const tooltipField = () => {
    if (!$slots.tooltip) return []
    // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
    // the field's closure so create()/update()/destroy() share it across the
    // tooltip's lifetime.
    let handle = null
    // Stable Tooltip object — its `create` reference never changes, so CM
    // reuses the TooltipView across caret moves (update-in-place, no remount).
    // NOTE: `create` is an ARROW-FUNCTION property and its param is named
    // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
    // panelExt: an object-method `create(view) {}` would get its own `this`,
    // and the Lit emitter's component-field rewrite walks into the body and
    // rewrites a `view`-named token (matching the top-level `let view`) to
    // `this.view`. An arrow property shares the enclosing scope, and the
    // non-colliding param name keeps the caret-view reference correct on every
    // target. CM calls `tooltip.create(view)` either way.
    const stableTooltip = {
      pos: 0,
      above: true,
      create: (tipView) => {
        const dom = document.createElement('div')
        dom.className = 'rozie-cm-tooltip'
        return {
          dom,
          mount: () => {
            handle = $portals.tooltip(dom, { view: tipView, pos: tipView.state.selection.main.head })
          },
          // Reactive in-place update — fired by CM on every ViewUpdate while the
          // tooltip view is reused. Re-renders the consumer fragment with the
          // fresh caret position; the fragment is NOT remounted (REQ — verified
          // empirically via the demo's mount/update counters).
          update: (u) => {
            handle?.update({ view: u.view, pos: u.state.selection.main.head })
          },
          destroy: () => {
            handle?.dispose()
            handle = null
          },
        }
      },
    }
    // NOTE: the StateField.update callback's first param is named `cur` (NOT the
    // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
    // local `value` binding into the prop-state ref (`_valueRef.current`) — it
    // walks into this callback and corrupts the field's accumulator
    // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
    // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
    // collision-free across all 6 targets.
    return StateField.define({
      create: (state) => ({ ...stableTooltip, pos: state.selection.main.head }),
      update: (cur, tr) => {
        // Keep the SAME stable `create`; only the head moves. Reuse the existing
        // object when the head is unchanged so the facet input is identity-stable.
        const head = tr.state.selection.main.head
        if (cur && cur.pos === head) return cur
        return { ...stableTooltip, pos: head }
      },
      provide: (f) => showTooltip.from(f),
    })
  }

  // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
  // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
  // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
  // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
  // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
  // the TipTap nodeView multi-instance template: the GutterMarker class captures
  // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
  //
  // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
  // subclassing), but its per-marker state (`line`, the live portal handle) lives
  // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
  // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
  // class fields assigned only in the constructor (`this.line = …`) without a
  // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
  // the class through verbatim (a class-field type aid is an emitter concern, OUT
  // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
  // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
  // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
  // so `override` is unparseable — so the three bundled leaves relax
  // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
  // now match). The `view` param is named `mView` — the Lit field-rewrite walks
  // into a method body and rewrites a bare `view` token (matching the top-level
  // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
  const makeGutterExt = (gv) => {
    if (!$slots.gutter) return []
    const makeGutterMarker = (line) => {
      let handle = null
      return new (class extends GutterMarker {
        toDOM(mView) {
          const dom = document.createElement('div')
          dom.className = 'rozie-cm-gutter-marker'
          handle = gv(dom, { line, view: mView })
          return dom
        }
        destroy() {
          handle?.dispose()
          handle = null
        }
      })()
    }
    // Recompute the marker RangeSet from `gutterLines` against the live doc —
    // one marker at the START of each in-range line. RangeSet.of REQUIRES the
    // ranges sorted by `from`, so sort the resolved positions.
    const buildMarkers = (mView) => {
      const doc = mView.state.doc
      const ranges = []
      for (const n of $props.gutterLines) {
        if (typeof n !== 'number' || n < 1 || n > doc.lines) continue
        ranges.push(makeGutterMarker(n).range(doc.line(n).from))
      }
      ranges.sort((a, b) => a.from - b.from)
      return RangeSet.of(ranges)
    }
    return gutterExt({
      class: 'rozie-cm-gutter',
      markers: (mView) => buildMarkers(mView),
    })
  }

  // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
  // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
  // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
  // portal handle PER VISIBLE widget. The decoration set is provided through a
  // compartment-wrapped facet so the `decorations` prop reconfigures it live.
  // The WidgetType class captures $portals.decoration, so it is defined inside
  // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
  const makeDecorationExt = (dv) => {
    // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
    // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
    // in the extensions array (via `decorationCompartment.of(...)`) makes
    // EditorState.create throw at runtime — the editor never mounts. Only the
    // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
    if (!$slots.decoration) return []
    // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
    // but its per-widget state (`from`/`to`, the live portal handle) lives in
    // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
    // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
    // `this` fields trip TS2339; the overriding methods can't carry the TS-only
    // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
    // `noImplicitOverride`). No `eq` override is needed: the decoration set is
    // rebuilt from the prop on every reconfigure, so default reference-`eq`
    // (always "different") correctly remounts each widget instead of reusing stale
    // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
    const makeWidget = (from, to) => {
      let handle = null
      return new (class extends WidgetType {
        toDOM(dView) {
          const dom = document.createElement('span')
          dom.className = 'rozie-cm-decoration'
          handle = dv(dom, { from, to, view: dView })
          return dom
        }
        destroy() {
          handle?.dispose()
          handle = null
        }
        // Inline widgets must not be considered editable content.
        ignoreEvent() {
          return false
        }
      })()
    }
    // Build the DecorationSet from `decorations` against the live doc. Each entry
    // is a point widget at `from` (side: 1 — after the position); out-of-range
    // offsets are clamped to the doc length and skipped if `from` is invalid.
    // Decoration.set REQUIRES the ranges sorted by `from`.
    const buildSet = (state) => {
      const len = state.doc.length
      const ranges = []
      for (const d of $props.decorations) {
        if (!d || typeof d.from !== 'number') continue
        const from = Math.max(0, Math.min(d.from, len))
        const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from
        ranges.push(Decoration.widget({ widget: makeWidget(from, to), side: 1 }).range(from))
      }
      ranges.sort((a, b) => a.from - b.from)
      return Decoration.set(ranges)
    }
    // A StateField yields the DecorationSet and provides it to EditorView.
    // decorations. The set is rebuilt on every prop-driven reconfigure (the
    // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
    // and tracked across local doc edits via mapping so widget positions follow.
    return StateField.define({
      create: (state) => buildSet(state),
      update: (deco, tr) => (tr.docChanged ? deco.map(tr.changes) : deco),
      provide: (f) => EditorView.decorations.from(f),
    })
  }

  // Bridge the mount-built factories to the top-level $watch reconfigures. Each
  // closes over the captured $portals helper so a prop change can rebuild the
  // extension without re-referencing $portals at top level.
  rebuildGutterExt = () => makeGutterExt($portals.gutter)
  rebuildDecorationExt = () => makeDecorationExt($portals.decoration)

  const buildState = (doc) => EditorState.create({
    doc,
    extensions: [
      ...baselineExt(),
      langCompartment.of(langExt()),
      themeCompartment.of(themeExt()),
      readOnlyCompartment.of(EditorState.readOnly.of($props.readOnly)),
      placeholderCompartment.of(phExt()),
      panelCompartment.of(panelExt()),
      topPanelCompartment.of(topPanelExt()),
      // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
      // 2). Each lives in a compartment so its driving prop (gutterLines /
      // decorations) reconfigures live; the factory captures the per-target
      // $portals helper (gutter / decoration) here in the mount scope.
      gutterCompartment.of(rebuildGutterExt()),
      decorationCompartment.of(rebuildDecorationExt()),
      // tooltipField() returns a StateField extension (or [] when the slot is
      // unfilled); no compartment — it is a one-shot mount-time decision.
      tooltipField(),
      EditorView.updateListener.of((update) => {
        if (!update.docChanged) return
        if (suppressEmit) return
        // Push the new doc out through the model:true emit path. Consumers
        // bound via `r-model:value="$data.x"` receive the change.
        $model.value = update.state.doc.toString()
      }),
      // Consumer extensions LAST so they win CM6's last-registered-wins facets.
      extensionsCompartment.of($props.extensions),
    ],
  })

  view = new EditorView({
    state: buildState($props.value),
    parent: $refs.hostEl,
  })
  return () => view?.destroy()
})

// Shared suppress-echo write helper. Both the $watch(value) consumer-driven
// reflect AND the $expose setValue verb route through this so a programmatic
// or prop-driven set doesn't ping-pong back through the model path. When the
// editor itself was the source of the change, the doc already matches `v`, so
// dispatching another transaction would mint a duplicate undo-history entry
// for no UI change.
const writeDoc = (v) => {
  if (!view) return
  const current = view.state.doc.toString()
  const next = v ?? ''
  if (current === next) return
  suppressEmit = true
  try {
    view.dispatch({
      changes: { from: 0, to: current.length, insert: next },
    })
  } finally {
    suppressEmit = false
  }
}

// Consumer-driven value writes: reflect into the live editor (echo-guarded).
$watch(() => $props.value, (v) => writeDoc(v))

$watch(() => $props.language, () => {
  if (!view) return
  view.dispatch({ effects: langCompartment.reconfigure(langExt()) })
})

$watch(() => $props.theme, () => {
  if (!view) return
  view.dispatch({ effects: themeCompartment.reconfigure(themeExt()) })
})

$watch(() => $props.readOnly, (v) => {
  if (!view) return
  view.dispatch({
    effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v)),
  })
})

$watch(() => $props.placeholder, () => {
  if (!view) return
  view.dispatch({ effects: placeholderCompartment.reconfigure(phExt()) })
})

$watch(() => $props.extensions, (v) => {
  if (!view) return
  view.dispatch({ effects: extensionsCompartment.reconfigure(v) })
})

// gutterLines / decorations (G5 wave 2) — rebuild the REACTIVE MULTI-INSTANCE
// portal extension from the new prop and reconfigure its compartment LIVE (no
// remount). The factory is mount-built (it captures $portals), bridged here via
// the component-scope rebuild* closures; both are null before mount.
$watch(() => $props.gutterLines, () => {
  if (!view || !rebuildGutterExt) return
  view.dispatch({ effects: gutterCompartment.reconfigure(rebuildGutterExt()) })
})

$watch(() => $props.decorations, () => {
  if (!view || !rebuildDecorationExt) return
  view.dispatch({ effects: decorationCompartment.reconfigure(rebuildDecorationExt()) })
})

// Imperative handle (Phase 21 $expose). The 12 editor verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets. Each guards
// the pre-mount/destroyed `view = null`. Collision-clear: none of the names
// collide with the props (value/language/theme/readOnly/height/placeholder/
// extensions/basicSetup/gutterLines/decorations) and there are no events (D-08).
//
// undo/redo/selectAll are the basic editor-command verbs a toolbar needs (history
// ships with basicSetup / the bundled `history()` extension); the @codemirror/
// commands functions are reached via the `cmCommands` namespace import so the
// public verb names don't self-shadow the imports. scrollToPos reveals a document
// position — it is NOT named `scrollIntoView`/`scrollTo` (both inherited
// HTMLElement methods → would shadow on the Lit leaf, the embla scrollTo lesson).
function getView()        { return view }
function focus()          { view?.focus() }
function getValue()       { return view ? view.state.doc.toString() : '' }
// replaceValue routes through the SAME suppress-echo guard as $watch(value).
// NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
// React auto-generate a `setValue` state setter, so a `setValue` $expose verb
// collides on the React target (ROZ524: "already declared" + recursive rewrite).
// Renamed to preserve the value-setter semantics collision-free across all 6
// targets. (Deviation from the locked D-06 name `setValue`.)
function replaceValue(v)  { writeDoc(v) }
function dispatch(tr)     { view?.dispatch(tr) }
function insertText(text) {
  if (!view) return
  const { from, to } = view.state.selection.main
  view.dispatch({ changes: { from, to, insert: text }, userEvent: 'input.type' })
}
function getSelection()   { return view ? view.state.selection.main : null }
function setSelection(range) {
  if (!view) return
  const sel = typeof range === 'number'
    ? EditorSelection.single(range)
    : EditorSelection.single(range.anchor, range.head)
  view.dispatch({ selection: sel })
}
function undo()      { if (view) cmCommands.undo(view) }
function redo()      { if (view) cmCommands.redo(view) }
function selectAll() { if (view) cmCommands.selectAll(view) }
// Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
// moves the caret but does not guarantee scroll; this dispatches the scroll
// effect. opts default centers the position vertically.
function scrollToPos(pos, opts) {
  if (!view) return
  view.dispatch({ effects: EditorView.scrollIntoView(pos, opts ?? { y: 'center' }) })
}

$expose({
  getView, focus, getValue, replaceValue, dispatch, insertText, getSelection, setSelection,
  undo, redo, selectAll, scrollToPos,
})
</script>

<template>
<div class="rozie-codemirror" :style="{ height: $props.height + 'px' }">
  <div class="cm-mount" ref="hostEl"></div>
</div>
<!--
  Portal-slot primitive (Spike 003). The `panel` slot is declared but NOT
  rendered in the template — per-target template emitters skip it. It exists
  only to declare the consumer-facing render-prop / scoped-slot / contentChild
  shape (per target). The wrapper invokes the slot from script via
  $portals.panel(dom, { view }) inside CM6's `showPanel` Panel.mount() hook;
  the portal helper mounts the consumer's fragment into the engine-owned panel
  node and returns a dispose handle the engine calls on Panel.destroy().
-->
<slot name="panel" portal :params="['view']" />
<!--
  topPanel — the TOP-docked mount-once portal sibling of `panel` (G5 wave 1).
  Declared but NOT rendered inline (per-target emitters skip it). The wrapper
  invokes it from script via $portals.topPanel(dom, { view }) inside CM6's
  `showPanel` Panel.mount() hook (with `top: true`). ROZ127-clean: `topPanel` ≠
  any prop (value/language/theme/readOnly/height/placeholder/extensions/basicSetup).
-->
<slot name="topPanel" portal :params="['view']" />
<!--
  tooltip — CodeMirror's FIRST REACTIVE portal slot (Phase 33 `reactive`
  primitive; G5 wave 1). Declared but NOT rendered inline. The wrapper invokes
  it from script via $portals.tooltip(dom, { view, pos }) inside the
  `showTooltip` StateField's TooltipView.mount() hook; the reactive handle
  ({ update, dispose }) re-renders the consumer fragment IN PLACE as the caret
  moves (TooltipView.update → handle.update(scope)) rather than remounting it.
  ROZ127-clean: `tooltip` ≠ any prop name.
-->
<slot name="tooltip" portal reactive :params="['view', 'pos']" />
<!--
  gutter — a REACTIVE MULTI-INSTANCE portal slot (Phase 33 `reactive` primitive;
  G5 wave 2). Declared but NOT rendered inline. The wrapper invokes it from script
  via $portals.gutter(dom, { line, view }) inside a custom CM6 `gutter()`'s
  GutterMarker.toDOM hook — ONE portal handle PER VISIBLE marker (the TipTap
  nodeView multi-instance template). Which lines get a marker is driven by the
  `gutterLines` prop; CM mounts a marker's fragment when its line scrolls into
  view and disposes it when it scrolls out. ROZ127-clean: `gutter` ≠ any prop
  name (value/language/theme/readOnly/height/placeholder/extensions/basicSetup/
  gutterLines/decorations).
-->
<slot name="gutter" portal reactive :params="['line', 'view']" />
<!--
  decoration — a REACTIVE MULTI-INSTANCE portal slot (Phase 33 `reactive`
  primitive; G5 wave 2). Declared but NOT rendered inline. The wrapper invokes it
  from script via $portals.decoration(dom, { from, to, view }) inside a
  `Decoration.widget` WidgetType.toDOM hook — ONE portal handle PER VISIBLE
  widget. Which document ranges get a widget is driven by the `decorations` prop
  ({ from, to? } 0-based offsets); CM mounts a widget's fragment when it scrolls
  into view and disposes it when it scrolls out. NOTE: the slot is `decoration`
  (singular) and the prop is `decorations` (plural) — distinct names, ROZ127-clean
  against each other AND every other prop/slot.
-->
<slot name="decoration" portal reactive :params="['from', 'to', 'view']" />
</template>

<style>
.rozie-codemirror {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 4px;
  overflow: hidden;
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}

.cm-mount {
  height: 100%;
  width: 100%;
}

:root {
  .rozie-codemirror .cm-editor {
    height: 100%;
    font-size: 13px;
  }

  .rozie-codemirror .cm-scroller {
    height: 100%;
  }

  .rozie-codemirror .rozie-cm-panel {
    padding: 2px 8px;
    border-top: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }

  /* topPanel docks at the TOP, so its divider border sits on the BOTTOM edge
     (mirror of .rozie-cm-panel's top border). */
  .rozie-codemirror .rozie-cm-panel-top {
    padding: 2px 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }

  /* tooltip — minimal chrome so a filled consumer fragment is visible; the
     consumer styles its own content. */
  .rozie-codemirror .rozie-cm-tooltip {
    padding: 2px 6px;
    font-size: 11px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 3px;
    white-space: nowrap;
  }

  /* gutter (G5 wave 2) — the custom gutter lane + its per-line marker host.
     Minimal chrome; the consumer styles its own marker content. */
  .rozie-codemirror .rozie-cm-gutter {
    min-width: 14px;
  }
  .rozie-codemirror .rozie-cm-gutter-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    font-size: 11px;
    line-height: 1;
  }

  /* decoration (G5 wave 2) — the inline-widget host. Minimal; the consumer
     styles its own widget content. */
  .rozie-codemirror .rozie-cm-decoration {
    display: inline-flex;
    align-items: center;
    vertical-align: text-bottom;
  }
}
</style>

</rozie>

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

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { useControllableState } from '@rozie/runtime-react';
import './CodeMirror.css';
import './CodeMirror.global.css';
import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';

interface PanelCtx { view: any; }

interface TopPanelCtx { view: any; }

interface TooltipCtx { view: any; pos: any; }

interface GutterCtx { line: any; view: any; }

interface DecorationCtx { from: any; to: any; view: any; }

interface CodeMirrorProps {
  /**
   * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
   * @example
   * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
   */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /**
   * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
   */
  language?: string;
  /**
   * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
   */
  theme?: unknown;
  /**
   * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
   */
  readOnly?: boolean;
  /**
   * Editor height in pixels, applied to the wrapper's host box.
   */
  height?: number;
  /**
   * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
   */
  placeholder?: string;
  /**
   * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
   */
  extensions?: any[];
  /**
   * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
   */
  basicSetup?: boolean;
  /**
   * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
   */
  gutterLines?: any[];
  /**
   * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
   */
  decorations?: any[];
  renderPanel?: (ctx: PanelCtx) => ReactNode;
  renderTopPanel?: (ctx: TopPanelCtx) => ReactNode;
  renderTooltip?: (ctx: TooltipCtx) => ReactNode;
  renderGutter?: (ctx: GutterCtx) => ReactNode;
  renderDecoration?: (ctx: DecorationCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface CodeMirrorHandle {
  getView: (...args: any[]) => any;
  focus: (...args: any[]) => any;
  getValue: (...args: any[]) => any;
  replaceValue: (...args: any[]) => any;
  dispatch: (...args: any[]) => any;
  insertText: (...args: any[]) => any;
  getSelection: (...args: any[]) => any;
  setSelection: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  selectAll: (...args: any[]) => any;
  scrollToPos: (...args: any[]) => any;
}

const CodeMirror = forwardRef<CodeMirrorHandle, CodeMirrorProps>(function CodeMirror(_props: CodeMirrorProps, ref): JSX.Element {
  const portalRoots = useRef<Set<Root>>(new Set());
  const __defaultExtensions = useState(() => (() => [])())[0];
  const __defaultGutterLines = useState(() => (() => [])())[0];
  const __defaultDecorations = useState(() => (() => [])())[0];
  const props: Omit<CodeMirrorProps, 'language' | 'theme' | 'readOnly' | 'height' | 'placeholder' | 'extensions' | 'basicSetup' | 'gutterLines' | 'decorations'> & { language: string; theme: unknown; readOnly: boolean; height: number; placeholder: string; extensions: any[]; basicSetup: boolean; gutterLines: any[]; decorations: any[] } = {
    ..._props,
    language: _props.language ?? 'javascript',
    theme: _props.theme ?? 'light',
    readOnly: _props.readOnly ?? false,
    height: _props.height ?? 240,
    placeholder: _props.placeholder ?? '',
    extensions: _props.extensions ?? __defaultExtensions,
    basicSetup: _props.basicSetup ?? false,
    gutterLines: _props.gutterLines ?? __defaultGutterLines,
    decorations: _props.decorations ?? __defaultDecorations,
  };
  const _renderPanelRef = useRef(props.renderPanel);
  _renderPanelRef.current = props.renderPanel;
  const _renderTopPanelRef = useRef(props.renderTopPanel);
  _renderTopPanelRef.current = props.renderTopPanel;
  const _renderTooltipRef = useRef(props.renderTooltip);
  _renderTooltipRef.current = props.renderTooltip;
  const _renderGutterRef = useRef(props.renderGutter);
  _renderGutterRef.current = props.renderGutter;
  const _renderDecorationRef = useRef(props.renderDecoration);
  _renderDecorationRef.current = props.renderDecoration;
  const rebuildGutterExt = useRef<any>(null);
  const rebuildDecorationExt = useRef<any>(null);
  const suppressEmit = useRef(false);
  const view = useRef<any>(null);
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? '',
    onValueChange: props.onValueChange,
  });
  const _decorationsRef = useRef(props.decorations);
  _decorationsRef.current = props.decorations;
  const _extensionsRef = useRef(props.extensions);
  _extensionsRef.current = props.extensions;
  const _gutterLinesRef = useRef(props.gutterLines);
  _gutterLinesRef.current = props.gutterLines;
  const _readOnlyRef = useRef(props.readOnly);
  _readOnlyRef.current = props.readOnly;
  const _valueRef = useRef(value);
  _valueRef.current = value;
  const hostEl = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);
  const _watch1First = useRef(true);
  const _watch2First = useRef(true);
  const _watch3First = useRef(true);
  const _watch4First = useRef(true);
  const _watch5First = useRef(true);
  const _watch6First = useRef(true);
  const _watch7First = useRef(true);

  const langCompartment = useMemo(() => new Compartment(), []);
  const themeCompartment = useMemo(() => new Compartment(), []);
  const readOnlyCompartment = useMemo(() => new Compartment(), []);
  const placeholderCompartment = useMemo(() => new Compartment(), []);
  const extensionsCompartment = useMemo(() => new Compartment(), []);
  const panelCompartment = useMemo(() => new Compartment(), []);
  const topPanelCompartment = useMemo(() => new Compartment(), []);
  const gutterCompartment = useMemo(() => new Compartment(), []);
  const decorationCompartment = useMemo(() => new Compartment(), []);
  const langExt = useCallback(() => props.language === 'javascript' ? javascript() : [], [props.language]);
  const themeExt = useCallback((): any => {
    const t = props.theme;
    if (t === 'dark') return oneDark;
    if (t === 'light' || t === '' || t == null) return [];
    // t is a CM Extension / Extension[] passthrough by this branch (the widened
    // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
    // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
    // accept it; the type-neutral targets strip types entirely.
    return t;
  }, [props.theme]);
  const phExt = useCallback(() => props.placeholder ? placeholderExt(props.placeholder) : [], [props.placeholder]);
  const baselineExt = useCallback(() => props.basicSetup ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])], [props.basicSetup]);
  function writeDoc(v: any) {
    if (!view.current) return;
    const current = view.current.state.doc.toString();
    const next = v ?? '';
    if (current === next) return;
    suppressEmit.current = true;
    try {
      view.current.dispatch({
        changes: {
          from: 0,
          to: current.length,
          insert: next
        }
      });
    } finally {
      suppressEmit.current = false;
    }
  }
  // Imperative handle (Phase 21 $expose). The 12 editor verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets. Each guards
  // the pre-mount/destroyed `view = null`. Collision-clear: none of the names
  // collide with the props (value/language/theme/readOnly/height/placeholder/
  // extensions/basicSetup/gutterLines/decorations) and there are no events (D-08).
  //
  // undo/redo/selectAll are the basic editor-command verbs a toolbar needs (history
  // ships with basicSetup / the bundled `history()` extension); the @codemirror/
  // commands functions are reached via the `cmCommands` namespace import so the
  // public verb names don't self-shadow the imports. scrollToPos reveals a document
  // position — it is NOT named `scrollIntoView`/`scrollTo` (both inherited
  // HTMLElement methods → would shadow on the Lit leaf, the embla scrollTo lesson).
  function getView() {
    return view.current;
  }
  function focus() {
    view.current?.focus();
  }
  function getValue() {
    return view.current ? view.current.state.doc.toString() : '';
  }
  // replaceValue routes through the SAME suppress-echo guard as $watch(value).
  // NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
  // React auto-generate a `setValue` state setter, so a `setValue` $expose verb
  // collides on the React target (ROZ524: "already declared" + recursive rewrite).
  // Renamed to preserve the value-setter semantics collision-free across all 6
  // targets. (Deviation from the locked D-06 name `setValue`.)
  // replaceValue routes through the SAME suppress-echo guard as $watch(value).
  // NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
  // React auto-generate a `setValue` state setter, so a `setValue` $expose verb
  // collides on the React target (ROZ524: "already declared" + recursive rewrite).
  // Renamed to preserve the value-setter semantics collision-free across all 6
  // targets. (Deviation from the locked D-06 name `setValue`.)
  function replaceValue(v: any) {
    writeDoc(v);
  }
  function dispatch(tr: any) {
    view.current?.dispatch(tr);
  }
  function insertText(text: any) {
    if (!view.current) return;
    const {
      from,
      to
    } = view.current.state.selection.main;
    view.current.dispatch({
      changes: {
        from,
        to,
        insert: text
      },
      userEvent: 'input.type'
    });
  }
  function getSelection() {
    return view.current ? view.current.state.selection.main : null;
  }
  function setSelection(range: any) {
    if (!view.current) return;
    const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
    view.current.dispatch({
      selection: sel
    });
  }
  function undo() {
    if (view.current) cmCommands.undo(view.current);
  }
  function redo() {
    if (view.current) cmCommands.redo(view.current);
  }
  function selectAll() {
    if (view.current) cmCommands.selectAll(view.current);
  }
  // Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
  // moves the caret but does not guarantee scroll; this dispatches the scroll
  // effect. opts default centers the position vertically.
  // Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
  // moves the caret but does not guarantee scroll; this dispatches the scroll
  // effect. opts default centers the position vertically.
  function scrollToPos(pos: any, opts: any) {
    if (!view.current) return;
    view.current.dispatch({
      effects: EditorView.scrollIntoView(pos, opts ?? {
        y: 'center'
      })
    });
  }

  useEffect(() => {
    interface ReactivePortalHandle {
    update(scope: unknown): void;
    dispose(): void;
  }
  const portals = {
    panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
      const slot = _renderPanelRef.current ?? props.slots?.['panel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal panel { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-panel', '34cfda5a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
      const slot = _renderTopPanelRef.current ?? props.slots?.['topPanel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal topPanel { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
      const slot = _renderTooltipRef.current ?? props.slots?.['tooltip'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal tooltip { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
      const root = createRoot(container);
      const renderScope = (s: { view: unknown; pos: unknown }): void => {
        flushSync(() => root.render(slot(s)));
      };
      renderScope(scope);
      portalRoots.current.add(root);
      return {
        update: (s: { view: unknown; pos: unknown }): void => renderScope(s),
        dispose: (): void => {
          root.unmount();
          portalRoots.current.delete(root);
        },
      };
    },
    gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
      const slot = _renderGutterRef.current ?? props.slots?.['gutter'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal gutter { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
      const root = createRoot(container);
      const renderScope = (s: { line: unknown; view: unknown }): void => {
        flushSync(() => root.render(slot(s)));
      };
      renderScope(scope);
      portalRoots.current.add(root);
      return {
        update: (s: { line: unknown; view: unknown }): void => renderScope(s),
        dispose: (): void => {
          root.unmount();
          portalRoots.current.delete(root);
        },
      };
    },
    decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
      const slot = _renderDecorationRef.current ?? props.slots?.['decoration'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal decoration { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
      const root = createRoot(container);
      const renderScope = (s: { from: unknown; to: unknown; view: unknown }): void => {
        flushSync(() => root.render(slot(s)));
      };
      renderScope(scope);
      portalRoots.current.add(root);
      return {
        update: (s: { from: unknown; to: unknown; view: unknown }): void => renderScope(s),
        dispose: (): void => {
          root.unmount();
          portalRoots.current.delete(root);
        },
      };
    },
  };
    // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
    // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
    // consumer's framework-native fragment on Panel.mount() and the returned
    // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
    // doesn't fill the slot.
    // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
    // `mount() {}` methods) and the panel host element + view are captured in
    // plain `const`s. The object-method form gives each method its own `this`
    // scope, and the Lit emitter's component-field rewrite walks INTO that method
    // body and rewrites a closure-captured `view` reference to `this.view`
    // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
    // properties share the enclosing lexical scope, so the captured `panelView`
    // const resolves correctly on every target. CM6 calls `panel.mount()` /
    // `panel.destroy()` either way.
    const panelExt = () => {
      if (!(props.renderPanel ?? props.slots?.["panel"])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: false,
          mount: () => {
            dispose = portals.panel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    const topPanelExt = () => {
      if (!(props.renderTopPanel ?? props.slots?.["topPanel"])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel-top';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: true,
          mount: () => {
            dispose = portals.topPanel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    const tooltipField = () => {
      if (!(props.renderTooltip ?? props.slots?.["tooltip"])) return [];
      // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
      // the field's closure so create()/update()/destroy() share it across the
      // tooltip's lifetime.
      let handle: any = null;
      // Stable Tooltip object — its `create` reference never changes, so CM
      // reuses the TooltipView across caret moves (update-in-place, no remount).
      // NOTE: `create` is an ARROW-FUNCTION property and its param is named
      // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
      // panelExt: an object-method `create(view) {}` would get its own `this`,
      // and the Lit emitter's component-field rewrite walks into the body and
      // rewrites a `view`-named token (matching the top-level `let view`) to
      // `this.view`. An arrow property shares the enclosing scope, and the
      // non-colliding param name keeps the caret-view reference correct on every
      // target. CM calls `tooltip.create(view)` either way.
      const stableTooltip = {
        pos: 0,
        above: true,
        create: (tipView: any) => {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-tooltip';
          return {
            dom,
            mount: () => {
              handle = portals.tooltip(dom, {
                view: tipView,
                pos: tipView.state.selection.main.head
              });
            },
            // Reactive in-place update — fired by CM on every ViewUpdate while the
            // tooltip view is reused. Re-renders the consumer fragment with the
            // fresh caret position; the fragment is NOT remounted (REQ — verified
            // empirically via the demo's mount/update counters).
            update: (u: any) => {
              handle?.update({
                view: u.view,
                pos: u.state.selection.main.head
              });
            },
            destroy: () => {
              handle?.dispose();
              handle = null;
            }
          };
        }
      };
      // NOTE: the StateField.update callback's first param is named `cur` (NOT the
      // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
      // local `value` binding into the prop-state ref (`_valueRef.current`) — it
      // walks into this callback and corrupts the field's accumulator
      // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
      // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
      // collision-free across all 6 targets.
      return StateField.define({
        create: (state: any) => ({
          ...stableTooltip,
          pos: state.selection.main.head
        }),
        update: (cur: any, tr: any) => {
          // Keep the SAME stable `create`; only the head moves. Reuse the existing
          // object when the head is unchanged so the facet input is identity-stable.
          const head = tr.state.selection.main.head;
          if (cur && cur.pos === head) return cur;
          return {
            ...stableTooltip,
            pos: head
          };
        },
        provide: (f: any) => showTooltip.from(f)
      });
    };

    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    const makeGutterExt = (gv: any) => {
      if (!(props.renderGutter ?? props.slots?.["gutter"])) return [];
      const makeGutterMarker = (line: any) => {
        let handle: any = null;
        return new class extends GutterMarker {
          toDOM(mView: any) {
            const dom = document.createElement('div');
            dom.className = 'rozie-cm-gutter-marker';
            handle = gv(dom, {
              line,
              view: mView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
        }();
      };
      // Recompute the marker RangeSet from `gutterLines` against the live doc —
      // one marker at the START of each in-range line. RangeSet.of REQUIRES the
      // ranges sorted by `from`, so sort the resolved positions.
      const buildMarkers = (mView: any): any => {
        const doc = mView.state.doc;
        const ranges = [];
        for (const n of _gutterLinesRef.current as any) {
          if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
          ranges.push(makeGutterMarker(n).range(doc.line(n).from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return RangeSet.of(ranges);
      };
      return gutterExt({
        class: 'rozie-cm-gutter',
        markers: (mView: any) => buildMarkers(mView)
      });
    };

    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    const makeDecorationExt = (dv: any) => {
      // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
      // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
      // in the extensions array (via `decorationCompartment.of(...)`) makes
      // EditorState.create throw at runtime — the editor never mounts. Only the
      // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
      if (!(props.renderDecoration ?? props.slots?.["decoration"])) return [];
      // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
      // but its per-widget state (`from`/`to`, the live portal handle) lives in
      // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
      // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
      // `this` fields trip TS2339; the overriding methods can't carry the TS-only
      // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
      // `noImplicitOverride`). No `eq` override is needed: the decoration set is
      // rebuilt from the prop on every reconfigure, so default reference-`eq`
      // (always "different") correctly remounts each widget instead of reusing stale
      // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
      const makeWidget = (from: any, to: any) => {
        let handle: any = null;
        return new class extends WidgetType {
          toDOM(dView: any) {
            const dom = document.createElement('span');
            dom.className = 'rozie-cm-decoration';
            handle = dv(dom, {
              from,
              to,
              view: dView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
          // Inline widgets must not be considered editable content.
          ignoreEvent() {
            return false;
          }
        }();
      };
      // Build the DecorationSet from `decorations` against the live doc. Each entry
      // is a point widget at `from` (side: 1 — after the position); out-of-range
      // offsets are clamped to the doc length and skipped if `from` is invalid.
      // Decoration.set REQUIRES the ranges sorted by `from`.
      const buildSet = (state: any) => {
        const len = state.doc.length;
        const ranges = [];
        for (const d of _decorationsRef.current as any) {
          if (!d || typeof d.from !== 'number') continue;
          const from = Math.max(0, Math.min(d.from, len));
          const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
          ranges.push(Decoration.widget({
            widget: makeWidget(from, to),
            side: 1
          }).range(from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return Decoration.set(ranges);
      };
      // A StateField yields the DecorationSet and provides it to EditorView.
      // decorations. The set is rebuilt on every prop-driven reconfigure (the
      // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
      // and tracked across local doc edits via mapping so widget positions follow.
      return StateField.define({
        create: (state: any) => buildSet(state),
        update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
        provide: (f: any) => EditorView.decorations.from(f)
      });
    };

    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    rebuildGutterExt.current = () => makeGutterExt(portals.gutter);
    rebuildDecorationExt.current = () => makeDecorationExt(portals.decoration);
    const buildState = (doc: any) => EditorState.create({
      doc,
      extensions: [...baselineExt(), langCompartment.of(langExt()), themeCompartment.of(themeExt()), readOnlyCompartment.of(EditorState.readOnly.of(_readOnlyRef.current)), placeholderCompartment.of(phExt()), panelCompartment.of(panelExt()), topPanelCompartment.of(topPanelExt()),
      // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
      // 2). Each lives in a compartment so its driving prop (gutterLines /
      // decorations) reconfigures live; the factory captures the per-target
      // $portals helper (gutter / decoration) here in the mount scope.
      gutterCompartment.of(rebuildGutterExt.current()), decorationCompartment.of(rebuildDecorationExt.current()),
      // tooltipField() returns a StateField extension (or [] when the slot is
      // unfilled); no compartment — it is a one-shot mount-time decision.
      tooltipField(), EditorView.updateListener.of((update: any) => {
        if (!update.docChanged) return;
        if (suppressEmit.current) return;
        // Push the new doc out through the model:true emit path. Consumers
        // bound via `r-model:value="$data.x"` receive the change.
        setValue(update.state.doc.toString());
      }),
      // Consumer extensions LAST so they win CM6's last-registered-wins facets.
      extensionsCompartment.of(_extensionsRef.current)]
    });
    view.current = new EditorView({
      state: buildState(_valueRef.current),
      parent: hostEl.current!
    });
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      view.current?.destroy();
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const v = value;
    writeDoc(v);
  }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    if (!view.current) return;
    view.current.dispatch({
      effects: langCompartment.reconfigure(langExt())
    });
  }, [props.language]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch2First.current) { _watch2First.current = false; return; }
    if (!view.current) return;
    view.current.dispatch({
      effects: themeCompartment.reconfigure(themeExt())
    });
  }, [props.theme]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch3First.current) { _watch3First.current = false; return; }
    const v = props.readOnly;
    if (!view.current) return;
    view.current.dispatch({
      effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
    });
  }, [props.readOnly]);
  useEffect(() => {
    if (_watch4First.current) { _watch4First.current = false; return; }
    if (!view.current) return;
    view.current.dispatch({
      effects: placeholderCompartment.reconfigure(phExt())
    });
  }, [props.placeholder]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch5First.current) { _watch5First.current = false; return; }
    const v = props.extensions;
    if (!view.current) return;
    view.current.dispatch({
      effects: extensionsCompartment.reconfigure(v)
    });
  }, [props.extensions]);
  useEffect(() => {
    if (_watch6First.current) { _watch6First.current = false; return; }
    if (!view.current || !rebuildGutterExt.current) return;
    view.current.dispatch({
      effects: gutterCompartment.reconfigure(rebuildGutterExt.current())
    });
  }, [props.gutterLines]);
  useEffect(() => {
    if (_watch7First.current) { _watch7First.current = false; return; }
    if (!view.current || !rebuildDecorationExt.current) return;
    view.current.dispatch({
      effects: decorationCompartment.reconfigure(rebuildDecorationExt.current())
    });
  }, [props.decorations]);

  const _rozieExposeRef = useRef({ getView, focus, getValue, replaceValue, dispatch, insertText, getSelection, setSelection, undo, redo, selectAll, scrollToPos });
  _rozieExposeRef.current = { getView, focus, getValue, replaceValue, dispatch, insertText, getSelection, setSelection, undo, redo, selectAll, scrollToPos };
  useImperativeHandle(ref, () => ({ getView: (...args: Parameters<typeof getView>): ReturnType<typeof getView> => _rozieExposeRef.current.getView(...args), focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), getValue: (...args: Parameters<typeof getValue>): ReturnType<typeof getValue> => _rozieExposeRef.current.getValue(...args), replaceValue: (...args: Parameters<typeof replaceValue>): ReturnType<typeof replaceValue> => _rozieExposeRef.current.replaceValue(...args), dispatch: (...args: Parameters<typeof dispatch>): ReturnType<typeof dispatch> => _rozieExposeRef.current.dispatch(...args), insertText: (...args: Parameters<typeof insertText>): ReturnType<typeof insertText> => _rozieExposeRef.current.insertText(...args), getSelection: (...args: Parameters<typeof getSelection>): ReturnType<typeof getSelection> => _rozieExposeRef.current.getSelection(...args), setSelection: (...args: Parameters<typeof setSelection>): ReturnType<typeof setSelection> => _rozieExposeRef.current.setSelection(...args), undo: (...args: Parameters<typeof undo>): ReturnType<typeof undo> => _rozieExposeRef.current.undo(...args), redo: (...args: Parameters<typeof redo>): ReturnType<typeof redo> => _rozieExposeRef.current.redo(...args), selectAll: (...args: Parameters<typeof selectAll>): ReturnType<typeof selectAll> => _rozieExposeRef.current.selectAll(...args), scrollToPos: (...args: Parameters<typeof scrollToPos>): ReturnType<typeof scrollToPos> => _rozieExposeRef.current.scrollToPos(...args) }), []);

  return (
    <>
    <div className={"rozie-codemirror"} style={{ height: props.height + 'px' }} data-rozie-s-34cfda5a="">
      <div className={"cm-mount"} ref={hostEl} data-rozie-s-34cfda5a="" />
    </div>










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

<div class="rozie-codemirror" :style="{ height: props.height + 'px' }">
  <div class="cm-mount" ref="hostElRef"></div>
</div>











</template>

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

const props = withDefaults(
  defineProps<{
    /**
     * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
     */
    language?: string;
    /**
     * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
     */
    theme?: unknown;
    /**
     * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
     */
    readOnly?: boolean;
    /**
     * Editor height in pixels, applied to the wrapper's host box.
     */
    height?: number;
    /**
     * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
     */
    placeholder?: string;
    /**
     * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
     */
    extensions?: any[];
    /**
     * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
     */
    basicSetup?: boolean;
    /**
     * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
     */
    gutterLines?: any[];
    /**
     * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
     */
    decorations?: any[];
  }>(),
  { language: 'javascript', theme: 'light' as any, readOnly: false, height: 240, placeholder: '', extensions: () => [], basicSetup: false, gutterLines: () => [], decorations: () => [] }
);

/**
 * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
 * @example
 * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
 */
const value = defineModel<string>('value', { default: '' });

defineSlots<{
  panel(props: { view: any }): any;
  topPanel(props: { view: any }): any;
  tooltip(props: { view: any; pos: any }): any;
  gutter(props: { line: any; view: any }): any;
  decoration(props: { from: any; to: any; view: any }): any;
}>();

const slots = useSlots();

const hostElRef = ref<HTMLElement>();

import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';
let view: any = null;

// CodeMirror's updateListener fires on EVERY transaction, including our own
// $watch-driven dispatch when the consumer changes `value`. Without a guard
// the wrapper would emit its own dispatch back through the model path on
// the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
// CodeMirror's updateListener fires on EVERY transaction, including our own
// $watch-driven dispatch when the consumer changes `value`. Without a guard
// the wrapper would emit its own dispatch back through the model path on
// the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
let suppressEmit = false;

// Compartments let us swap individual extensions at runtime via
// `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
// rebuilding the entire EditorState. Each runtime-updatable prop gets one so
// prop changes don't lose cursor/history/scroll position.
// Compartments let us swap individual extensions at runtime via
// `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
// rebuilding the entire EditorState. Each runtime-updatable prop gets one so
// prop changes don't lose cursor/history/scroll position.
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
const readOnlyCompartment = new Compartment();
const placeholderCompartment = new Compartment();
const extensionsCompartment = new Compartment();
const panelCompartment = new Compartment();
// topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
// slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
// topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
// slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
const topPanelCompartment = new Compartment();
// gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
// one portal handle per visible marker/widget (the TipTap nodeView template).
// Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
// reconfigures the marked lines / decorated ranges LIVE with no remount, like
// every other runtime-updatable prop. The GutterMarker/WidgetType classes that
// capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
// top-level $portals reference fails the bundled-leaf strict typecheck — the
// panel/tooltip/nodeView discipline), so these compartments are filled from
// factories invoked in the mount body.
// gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
// one portal handle per visible marker/widget (the TipTap nodeView template).
// Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
// reconfigures the marked lines / decorated ranges LIVE with no remount, like
// every other runtime-updatable prop. The GutterMarker/WidgetType classes that
// capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
// top-level $portals reference fails the bundled-leaf strict typecheck — the
// panel/tooltip/nodeView discipline), so these compartments are filled from
// factories invoked in the mount body.
const gutterCompartment = new Compartment();
const decorationCompartment = new Compartment();
// The gutter / decoration extension FACTORIES capture the per-target $portals
// helper, so they MUST be built inside $onMount (a top-level $portals reference
// fails the bundled-leaf strict typecheck). But the gutterLines / decorations
// $watch reconfigures are top-level and need to rebuild the extension on prop
// change. Bridge with these component-scope `let`s: $onMount assigns each to its
// mount-built factory; the $watch closures call through them (no-op before mount
// or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
// top-level $watch can reach them — the same hoist the TipTap toolbarDispose
// uses for a mount-built handle referenced from outside the mount body.
// The gutter / decoration extension FACTORIES capture the per-target $portals
// helper, so they MUST be built inside $onMount (a top-level $portals reference
// fails the bundled-leaf strict typecheck). But the gutterLines / decorations
// $watch reconfigures are top-level and need to rebuild the extension on prop
// change. Bridge with these component-scope `let`s: $onMount assigns each to its
// mount-built factory; the $watch closures call through them (no-op before mount
// or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
// top-level $watch can reach them — the same hoist the TipTap toolbarDispose
// uses for a mount-built handle referenced from outside the mount body.
let rebuildGutterExt: any = null;
let rebuildDecorationExt: any = null;
// tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
// cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
// (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
// portal handle re-renders the consumer fragment IN PLACE on caret move rather
// than remounting it each keystroke. NO compartment — a StateField is the
// idiomatic showTooltip source and there is no runtime prop to reconfigure it
// against (slot presence is decided once at mount).

// language is a convenience prop mapping to the ONE bundled language
// (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
// highlighting); consumers add other languages via :extensions (D-03). This
// FIXES the prior declared-but-ignored bug where buildState hard-coded
// javascript() regardless of $props.language.
// tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
// cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
// (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
// portal handle re-renders the consumer fragment IN PLACE on caret move rather
// than remounting it each keystroke. NO compartment — a StateField is the
// idiomatic showTooltip source and there is no runtime prop to reconfigure it
// against (slot presence is decided once at mount).

// language is a convenience prop mapping to the ONE bundled language
// (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
// highlighting); consumers add other languages via :extensions (D-03). This
// FIXES the prior declared-but-ignored bug where buildState hard-coded
// javascript() regardless of $props.language.
const langExt = () => props.language === 'javascript' ? javascript() : [];

// theme resolution (G3): the two built-in strings map to oneDark / [];
// anything else is treated as a CM Extension (or Extension[]) and passed
// straight through the themeCompartment. The $watch(theme) reconfigure below
// covers extension themes live, identical to the string forms.
// theme resolution (G3): the two built-in strings map to oneDark / [];
// anything else is treated as a CM Extension (or Extension[]) and passed
// straight through the themeCompartment. The $watch(theme) reconfigure below
// covers extension themes live, identical to the string forms.
const themeExt = (): any => {
  const t = props.theme;
  if (t === 'dark') return oneDark;
  if (t === 'light' || t === '' || t == null) return [];
  // t is a CM Extension / Extension[] passthrough by this branch (the widened
  // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
  // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
  // accept it; the type-neutral targets strip types entirely.
  return t;
};

// placeholder ext only when a non-empty placeholder string is supplied.
// placeholder ext only when a non-empty placeholder string is supplied.
const phExt = () => props.placeholder ? placeholderExt(props.placeholder) : [];

// baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
// `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
// lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
// the manual trio would double those up). When off, keep the exact thin
// baseline the wrapper shipped before G1 (line numbers + history + default/
// history keymaps) → existing consumers stay byte-stable. Read at buildState
// time only — no compartment (see the basicSetup prop note).
// baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
// `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
// lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
// the manual trio would double those up). When off, keep the exact thin
// baseline the wrapper shipped before G1 (line numbers + history + default/
// history keymaps) → existing consumers stay byte-stable. Read at buildState
// time only — no compartment (see the basicSetup prop note).
const baselineExt = () => props.basicSetup ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])];

// buildState + the panel-slot wiring live INSIDE $onMount so the $portals.panel
// reference is bound in the mount scope. The per-target emitters scope the
// concrete portal helper inside the mount lifecycle (React useEffect / Lit
// firstUpdated / etc.); a top-level `panelExt` that references $portals would
// land out-of-scope of that helper and fail the bundled-leaf strict typecheck
// (TS2304 'portals' / TS2742). Keeping the $portals.panel use inside $onMount
// mirrors FullCalendar's portal pattern (its eventContent callbacks reference
// $portals only inside $onMount). buildState is only ever called here, so this
// is a behavior-preserving relocation. Compartments + langExt/themeExt/phExt
// stay top-level — the $watch reconfigures still reference them.
// Shared suppress-echo write helper. Both the $watch(value) consumer-driven
// reflect AND the $expose setValue verb route through this so a programmatic
// or prop-driven set doesn't ping-pong back through the model path. When the
// editor itself was the source of the change, the doc already matches `v`, so
// dispatching another transaction would mint a duplicate undo-history entry
// for no UI change.
const writeDoc = (v: any) => {
  if (!view) return;
  const current = view.state.doc.toString();
  const next = v ?? '';
  if (current === next) return;
  suppressEmit = true;
  try {
    view.dispatch({
      changes: {
        from: 0,
        to: current.length,
        insert: next
      }
    });
  } finally {
    suppressEmit = false;
  }
};

// Consumer-driven value writes: reflect into the live editor (echo-guarded).
// Imperative handle (Phase 21 $expose). The 12 editor verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets. Each guards
// the pre-mount/destroyed `view = null`. Collision-clear: none of the names
// collide with the props (value/language/theme/readOnly/height/placeholder/
// extensions/basicSetup/gutterLines/decorations) and there are no events (D-08).
//
// undo/redo/selectAll are the basic editor-command verbs a toolbar needs (history
// ships with basicSetup / the bundled `history()` extension); the @codemirror/
// commands functions are reached via the `cmCommands` namespace import so the
// public verb names don't self-shadow the imports. scrollToPos reveals a document
// position — it is NOT named `scrollIntoView`/`scrollTo` (both inherited
// HTMLElement methods → would shadow on the Lit leaf, the embla scrollTo lesson).
function getView() {
  return view;
}
function focus() {
  view?.focus();
}
function getValue() {
  return view ? view.state.doc.toString() : '';
}
// replaceValue routes through the SAME suppress-echo guard as $watch(value).
// NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
// React auto-generate a `setValue` state setter, so a `setValue` $expose verb
// collides on the React target (ROZ524: "already declared" + recursive rewrite).
// Renamed to preserve the value-setter semantics collision-free across all 6
// targets. (Deviation from the locked D-06 name `setValue`.)
// replaceValue routes through the SAME suppress-echo guard as $watch(value).
// NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
// React auto-generate a `setValue` state setter, so a `setValue` $expose verb
// collides on the React target (ROZ524: "already declared" + recursive rewrite).
// Renamed to preserve the value-setter semantics collision-free across all 6
// targets. (Deviation from the locked D-06 name `setValue`.)
function replaceValue(v: any) {
  writeDoc(v);
}
function dispatch(tr: any) {
  view?.dispatch(tr);
}
function insertText(text: any) {
  if (!view) return;
  const {
    from,
    to
  } = view.state.selection.main;
  view.dispatch({
    changes: {
      from,
      to,
      insert: text
    },
    userEvent: 'input.type'
  });
}
function getSelection() {
  return view ? view.state.selection.main : null;
}
function setSelection(range: any) {
  if (!view) return;
  const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
  view.dispatch({
    selection: sel
  });
}
function undo() {
  if (view) cmCommands.undo(view);
}
function redo() {
  if (view) cmCommands.redo(view);
}
function selectAll() {
  if (view) cmCommands.selectAll(view);
}
// Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
// moves the caret but does not guarantee scroll; this dispatches the scroll
// effect. opts default centers the position vertically.
// Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
// moves the caret but does not guarantee scroll; this dispatches the scroll
// effect. opts default centers the position vertically.
function scrollToPos(pos: any, opts: any) {
  if (!view) return;
  view.dispatch({
    effects: EditorView.scrollIntoView(pos, opts ?? {
      y: 'center'
    })
  });
}

interface ReactivePortalHandle {
  update(scope: unknown): void;
  dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
  panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
    const slotFn = slots.panel;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // panel { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-panel', '34cfda5a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
    const slotFn = slots.topPanel;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // topPanel { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
    const slotFn = slots.tooltip;
    if (!slotFn) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // tooltip { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
    const renderScope = (s: unknown): void => {
      render(h(Fragment, null, slotFn(s)), container);
    };
    renderScope(scope);
    portalContainers.add(container);
    return {
      update: (s: unknown): void => renderScope(s),
      dispose: (): void => {
        render(null, container);
        portalContainers.delete(container);
      },
    };
  },
  gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
    const slotFn = slots.gutter;
    if (!slotFn) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // gutter { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
    const renderScope = (s: unknown): void => {
      render(h(Fragment, null, slotFn(s)), container);
    };
    renderScope(scope);
    portalContainers.add(container);
    return {
      update: (s: unknown): void => renderScope(s),
      dispose: (): void => {
        render(null, container);
        portalContainers.delete(container);
      },
    };
  },
  decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
    const slotFn = slots.decoration;
    if (!slotFn) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // decoration { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
    const renderScope = (s: unknown): void => {
      render(h(Fragment, null, slotFn(s)), container);
    };
    renderScope(scope);
    portalContainers.add(container);
    return {
      update: (s: unknown): void => renderScope(s),
      dispose: (): void => {
        render(null, container);
        portalContainers.delete(container);
      },
    };
  },
};
onBeforeUnmount(() => {
  for (const container of portalContainers) render(null, container);
  portalContainers.clear();
});

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
  // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
  // consumer's framework-native fragment on Panel.mount() and the returned
  // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
  // doesn't fill the slot.
  // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
  // `mount() {}` methods) and the panel host element + view are captured in
  // plain `const`s. The object-method form gives each method its own `this`
  // scope, and the Lit emitter's component-field rewrite walks INTO that method
  // body and rewrites a closure-captured `view` reference to `this.view`
  // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
  // properties share the enclosing lexical scope, so the captured `panelView`
  // const resolves correctly on every target. CM6 calls `panel.mount()` /
  // `panel.destroy()` either way.
  const panelExt = () => {
    if (!slots.panel) return [];
    return showPanel.of((panelView: any) => {
      const dom = document.createElement('div');
      dom.className = 'rozie-cm-panel';
      const scope = {
        view: panelView
      };
      let dispose: any = null;
      return {
        dom,
        top: false,
        mount: () => {
          dispose = portals.panel(dom, scope);
        },
        destroy: () => {
          dispose?.();
          dispose = null;
        }
      };
    });
  };

  // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
  // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
  // object-method `mount() {}` — the Lit field-rewrite caveat documented on
  // panelExt above applies identically), differing ONLY in `top: true` and the
  // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
  const topPanelExt = () => {
    if (!slots.topPanel) return [];
    return showPanel.of((panelView: any) => {
      const dom = document.createElement('div');
      dom.className = 'rozie-cm-panel-top';
      const scope = {
        view: panelView
      };
      let dispose: any = null;
      return {
        dom,
        top: true,
        mount: () => {
          dispose = portals.topPanel(dom, scope);
        },
        destroy: () => {
          dispose?.();
          dispose = null;
        }
      };
    });
  };

  // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
  // cursor-anchored tooltip provided through the `showTooltip` facet via a
  // StateField that yields ONE Tooltip at the main selection head whenever the
  // `tooltip` slot is filled.
  //
  // UPDATE-IN-PLACE reconciliation (verified against the installed
  // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
  // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
  // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
  // old one's (`other.create == tip.create`); and when the whole showTooltip
  // facet INPUT is unchanged it skips matching entirely and calls update() on
  // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
  // (`stableTooltip`, stable `create`) and returning that SAME object from the
  // field's `update` while the head only moved. So the consumer fragment mounts
  // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
  // every caret move flows through TooltipView.update → handle.update(scope) —
  // re-rendering the fragment IN PLACE, never remounting it.
  const tooltipField = () => {
    if (!slots.tooltip) return [];
    // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
    // the field's closure so create()/update()/destroy() share it across the
    // tooltip's lifetime.
    let handle: any = null;
    // Stable Tooltip object — its `create` reference never changes, so CM
    // reuses the TooltipView across caret moves (update-in-place, no remount).
    // NOTE: `create` is an ARROW-FUNCTION property and its param is named
    // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
    // panelExt: an object-method `create(view) {}` would get its own `this`,
    // and the Lit emitter's component-field rewrite walks into the body and
    // rewrites a `view`-named token (matching the top-level `let view`) to
    // `this.view`. An arrow property shares the enclosing scope, and the
    // non-colliding param name keeps the caret-view reference correct on every
    // target. CM calls `tooltip.create(view)` either way.
    const stableTooltip = {
      pos: 0,
      above: true,
      create: (tipView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-tooltip';
        return {
          dom,
          mount: () => {
            handle = portals.tooltip(dom, {
              view: tipView,
              pos: tipView.state.selection.main.head
            });
          },
          // Reactive in-place update — fired by CM on every ViewUpdate while the
          // tooltip view is reused. Re-renders the consumer fragment with the
          // fresh caret position; the fragment is NOT remounted (REQ — verified
          // empirically via the demo's mount/update counters).
          update: (u: any) => {
            handle?.update({
              view: u.view,
              pos: u.state.selection.main.head
            });
          },
          destroy: () => {
            handle?.dispose();
            handle = null;
          }
        };
      }
    };
    // NOTE: the StateField.update callback's first param is named `cur` (NOT the
    // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
    // local `value` binding into the prop-state ref (`_valueRef.current`) — it
    // walks into this callback and corrupts the field's accumulator
    // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
    // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
    // collision-free across all 6 targets.
    return StateField.define({
      create: (state: any) => ({
        ...stableTooltip,
        pos: state.selection.main.head
      }),
      update: (cur: any, tr: any) => {
        // Keep the SAME stable `create`; only the head moves. Reuse the existing
        // object when the head is unchanged so the facet input is identity-stable.
        const head = tr.state.selection.main.head;
        if (cur && cur.pos === head) return cur;
        return {
          ...stableTooltip,
          pos: head
        };
      },
      provide: (f: any) => showTooltip.from(f)
    });
  };

  // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
  // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
  // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
  // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
  // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
  // the TipTap nodeView multi-instance template: the GutterMarker class captures
  // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
  //
  // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
  // subclassing), but its per-marker state (`line`, the live portal handle) lives
  // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
  // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
  // class fields assigned only in the constructor (`this.line = …`) without a
  // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
  // the class through verbatim (a class-field type aid is an emitter concern, OUT
  // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
  // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
  // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
  // so `override` is unparseable — so the three bundled leaves relax
  // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
  // now match). The `view` param is named `mView` — the Lit field-rewrite walks
  // into a method body and rewrites a bare `view` token (matching the top-level
  // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
  const makeGutterExt = (gv: any) => {
    if (!slots.gutter) return [];
    const makeGutterMarker = (line: any) => {
      let handle: any = null;
      return new class extends GutterMarker {
        toDOM(mView: any) {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-gutter-marker';
          handle = gv(dom, {
            line,
            view: mView
          });
          return dom;
        }
        destroy() {
          handle?.dispose();
          handle = null;
        }
      }();
    };
    // Recompute the marker RangeSet from `gutterLines` against the live doc —
    // one marker at the START of each in-range line. RangeSet.of REQUIRES the
    // ranges sorted by `from`, so sort the resolved positions.
    const buildMarkers = (mView: any): any => {
      const doc = mView.state.doc;
      const ranges = [];
      for (const n of props.gutterLines as any) {
        if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
        ranges.push(makeGutterMarker(n).range(doc.line(n).from));
      }
      ranges.sort((a: any, b: any) => a.from - b.from);
      return RangeSet.of(ranges);
    };
    return gutterExt({
      class: 'rozie-cm-gutter',
      markers: (mView: any) => buildMarkers(mView)
    });
  };

  // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
  // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
  // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
  // portal handle PER VISIBLE widget. The decoration set is provided through a
  // compartment-wrapped facet so the `decorations` prop reconfigures it live.
  // The WidgetType class captures $portals.decoration, so it is defined inside
  // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
  const makeDecorationExt = (dv: any) => {
    // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
    // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
    // in the extensions array (via `decorationCompartment.of(...)`) makes
    // EditorState.create throw at runtime — the editor never mounts. Only the
    // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
    if (!slots.decoration) return [];
    // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
    // but its per-widget state (`from`/`to`, the live portal handle) lives in
    // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
    // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
    // `this` fields trip TS2339; the overriding methods can't carry the TS-only
    // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
    // `noImplicitOverride`). No `eq` override is needed: the decoration set is
    // rebuilt from the prop on every reconfigure, so default reference-`eq`
    // (always "different") correctly remounts each widget instead of reusing stale
    // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
    const makeWidget = (from: any, to: any) => {
      let handle: any = null;
      return new class extends WidgetType {
        toDOM(dView: any) {
          const dom = document.createElement('span');
          dom.className = 'rozie-cm-decoration';
          handle = dv(dom, {
            from,
            to,
            view: dView
          });
          return dom;
        }
        destroy() {
          handle?.dispose();
          handle = null;
        }
        // Inline widgets must not be considered editable content.
        ignoreEvent() {
          return false;
        }
      }();
    };
    // Build the DecorationSet from `decorations` against the live doc. Each entry
    // is a point widget at `from` (side: 1 — after the position); out-of-range
    // offsets are clamped to the doc length and skipped if `from` is invalid.
    // Decoration.set REQUIRES the ranges sorted by `from`.
    const buildSet = (state: any) => {
      const len = state.doc.length;
      const ranges = [];
      for (const d of props.decorations as any) {
        if (!d || typeof d.from !== 'number') continue;
        const from = Math.max(0, Math.min(d.from, len));
        const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
        ranges.push(Decoration.widget({
          widget: makeWidget(from, to),
          side: 1
        }).range(from));
      }
      ranges.sort((a: any, b: any) => a.from - b.from);
      return Decoration.set(ranges);
    };
    // A StateField yields the DecorationSet and provides it to EditorView.
    // decorations. The set is rebuilt on every prop-driven reconfigure (the
    // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
    // and tracked across local doc edits via mapping so widget positions follow.
    return StateField.define({
      create: (state: any) => buildSet(state),
      update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
      provide: (f: any) => EditorView.decorations.from(f)
    });
  };

  // Bridge the mount-built factories to the top-level $watch reconfigures. Each
  // closes over the captured $portals helper so a prop change can rebuild the
  // extension without re-referencing $portals at top level.
  rebuildGutterExt = () => makeGutterExt(portals.gutter);
  rebuildDecorationExt = () => makeDecorationExt(portals.decoration);
  const buildState = (doc: any) => EditorState.create({
    doc,
    extensions: [...baselineExt(), langCompartment.of(langExt()), themeCompartment.of(themeExt()), readOnlyCompartment.of(EditorState.readOnly.of(props.readOnly)), placeholderCompartment.of(phExt()), panelCompartment.of(panelExt()), topPanelCompartment.of(topPanelExt()),
    // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
    // 2). Each lives in a compartment so its driving prop (gutterLines /
    // decorations) reconfigures live; the factory captures the per-target
    // $portals helper (gutter / decoration) here in the mount scope.
    gutterCompartment.of(rebuildGutterExt()), decorationCompartment.of(rebuildDecorationExt()),
    // tooltipField() returns a StateField extension (or [] when the slot is
    // unfilled); no compartment — it is a one-shot mount-time decision.
    tooltipField(), EditorView.updateListener.of((update: any) => {
      if (!update.docChanged) return;
      if (suppressEmit) return;
      // Push the new doc out through the model:true emit path. Consumers
      // bound via `r-model:value="$data.x"` receive the change.
      value.value = update.state.doc.toString();
    }),
    // Consumer extensions LAST so they win CM6's last-registered-wins facets.
    extensionsCompartment.of(props.extensions)]
  });
  view = new EditorView({
    state: buildState(value.value),
    parent: hostElRef.value!
  });
  _cleanup_0 = () => view?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => value.value, (v: any) => writeDoc(v));
watch(() => props.language, () => {
  if (!view) return;
  view.dispatch({
    effects: langCompartment.reconfigure(langExt())
  });
});
watch(() => props.theme, () => {
  if (!view) return;
  view.dispatch({
    effects: themeCompartment.reconfigure(themeExt())
  });
});
watch(() => props.readOnly, (v: any) => {
  if (!view) return;
  view.dispatch({
    effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
  });
});
watch(() => props.placeholder, () => {
  if (!view) return;
  view.dispatch({
    effects: placeholderCompartment.reconfigure(phExt())
  });
});
watch(() => props.extensions, (v: any) => {
  if (!view) return;
  view.dispatch({
    effects: extensionsCompartment.reconfigure(v)
  });
});
watch(() => props.gutterLines, () => {
  if (!view || !rebuildGutterExt) return;
  view.dispatch({
    effects: gutterCompartment.reconfigure(rebuildGutterExt())
  });
});
watch(() => props.decorations, () => {
  if (!view || !rebuildDecorationExt) return;
  view.dispatch({
    effects: decorationCompartment.reconfigure(rebuildDecorationExt())
  });
});

defineExpose({ getView, focus, getValue, replaceValue, dispatch, insertText, getSelection, setSelection, undo, redo, selectAll, scrollToPos });
</script>

<style scoped>
.rozie-codemirror {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 4px;
  overflow: hidden;
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}
.cm-mount {
  height: 100%;
  width: 100%;
}
</style>

<style>
.rozie-codemirror .cm-editor {
    height: 100%;
    font-size: 13px;
  }
.rozie-codemirror .cm-scroller {
    height: 100%;
  }
.rozie-codemirror .rozie-cm-panel {
    padding: 2px 8px;
    border-top: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-panel-top {
    padding: 2px 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-tooltip {
    padding: 2px 6px;
    font-size: 11px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 3px;
    white-space: nowrap;
  }
.rozie-codemirror .rozie-cm-gutter {
    min-width: 14px;
  }
.rozie-codemirror .rozie-cm-gutter-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    font-size: 11px;
    line-height: 1;
  }
.rozie-codemirror .rozie-cm-decoration {
    display: inline-flex;
    align-items: center;
    vertical-align: text-bottom;
  }
</style>
svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import PortalHostReactive from '@rozie/runtime-svelte/PortalHostReactive.svelte';
import { onMount, untrack } from 'svelte';

interface Props {
  /**
   * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
   * @example
   * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
   */
  value?: string;
  /**
   * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
   */
  language?: string;
  /**
   * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
   */
  theme?: unknown;
  /**
   * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
   */
  readOnly?: boolean;
  /**
   * Editor height in pixels, applied to the wrapper's host box.
   */
  height?: number;
  /**
   * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
   */
  placeholder?: string;
  /**
   * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
   */
  extensions?: any[];
  /**
   * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
   */
  basicSetup?: boolean;
  /**
   * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
   */
  gutterLines?: any[];
  /**
   * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
   */
  decorations?: any[];
  panel?: Snippet<[{ view: any }]>;
  topPanel?: Snippet<[{ view: any }]>;
  tooltip?: Snippet<[{ view: any; pos: any }]>;
  gutter?: Snippet<[{ line: any; view: any }]>;
  decoration?: Snippet<[{ from: any; to: any; view: any }]>;
  snippets?: Record<string, any>;
}

let __defaultExtensions = (() => [])();
let __defaultGutterLines = (() => [])();
let __defaultDecorations = (() => [])();

let {
  value = $bindable(''),
  language = 'javascript',
  theme = 'light',
  readOnly = false,
  height = 240,
  placeholder = '',
  extensions = __defaultExtensions,
  basicSetup = false,
  gutterLines = __defaultGutterLines,
  decorations = __defaultDecorations,
  panel: __panelProp,
  topPanel: __topPanelProp,
  tooltip: __tooltipProp,
  gutter: __gutterProp,
  decoration: __decorationProp,
  snippets
}: Props = $props();

const panel = $derived(__panelProp ?? snippets?.panel);
const topPanel = $derived(__topPanelProp ?? snippets?.topPanel);
const tooltip = $derived(__tooltipProp ?? snippets?.tooltip);
const gutter = $derived(__gutterProp ?? snippets?.gutter);
const decoration = $derived(__decorationProp ?? snippets?.decoration);

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

import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';
let view: any = null;

// CodeMirror's updateListener fires on EVERY transaction, including our own
// $watch-driven dispatch when the consumer changes `value`. Without a guard
// the wrapper would emit its own dispatch back through the model path on
// the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
// CodeMirror's updateListener fires on EVERY transaction, including our own
// $watch-driven dispatch when the consumer changes `value`. Without a guard
// the wrapper would emit its own dispatch back through the model path on
// the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
let suppressEmit = false;

// Compartments let us swap individual extensions at runtime via
// `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
// rebuilding the entire EditorState. Each runtime-updatable prop gets one so
// prop changes don't lose cursor/history/scroll position.
// Compartments let us swap individual extensions at runtime via
// `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
// rebuilding the entire EditorState. Each runtime-updatable prop gets one so
// prop changes don't lose cursor/history/scroll position.
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
const readOnlyCompartment = new Compartment();
const placeholderCompartment = new Compartment();
const extensionsCompartment = new Compartment();
const panelCompartment = new Compartment();
// topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
// slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
// topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
// slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
const topPanelCompartment = new Compartment();
// gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
// one portal handle per visible marker/widget (the TipTap nodeView template).
// Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
// reconfigures the marked lines / decorated ranges LIVE with no remount, like
// every other runtime-updatable prop. The GutterMarker/WidgetType classes that
// capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
// top-level $portals reference fails the bundled-leaf strict typecheck — the
// panel/tooltip/nodeView discipline), so these compartments are filled from
// factories invoked in the mount body.
// gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
// one portal handle per visible marker/widget (the TipTap nodeView template).
// Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
// reconfigures the marked lines / decorated ranges LIVE with no remount, like
// every other runtime-updatable prop. The GutterMarker/WidgetType classes that
// capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
// top-level $portals reference fails the bundled-leaf strict typecheck — the
// panel/tooltip/nodeView discipline), so these compartments are filled from
// factories invoked in the mount body.
const gutterCompartment = new Compartment();
const decorationCompartment = new Compartment();
// The gutter / decoration extension FACTORIES capture the per-target $portals
// helper, so they MUST be built inside $onMount (a top-level $portals reference
// fails the bundled-leaf strict typecheck). But the gutterLines / decorations
// $watch reconfigures are top-level and need to rebuild the extension on prop
// change. Bridge with these component-scope `let`s: $onMount assigns each to its
// mount-built factory; the $watch closures call through them (no-op before mount
// or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
// top-level $watch can reach them — the same hoist the TipTap toolbarDispose
// uses for a mount-built handle referenced from outside the mount body.
// The gutter / decoration extension FACTORIES capture the per-target $portals
// helper, so they MUST be built inside $onMount (a top-level $portals reference
// fails the bundled-leaf strict typecheck). But the gutterLines / decorations
// $watch reconfigures are top-level and need to rebuild the extension on prop
// change. Bridge with these component-scope `let`s: $onMount assigns each to its
// mount-built factory; the $watch closures call through them (no-op before mount
// or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
// top-level $watch can reach them — the same hoist the TipTap toolbarDispose
// uses for a mount-built handle referenced from outside the mount body.
let rebuildGutterExt: any = null;
let rebuildDecorationExt: any = null;
// tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
// cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
// (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
// portal handle re-renders the consumer fragment IN PLACE on caret move rather
// than remounting it each keystroke. NO compartment — a StateField is the
// idiomatic showTooltip source and there is no runtime prop to reconfigure it
// against (slot presence is decided once at mount).

// language is a convenience prop mapping to the ONE bundled language
// (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
// highlighting); consumers add other languages via :extensions (D-03). This
// FIXES the prior declared-but-ignored bug where buildState hard-coded
// javascript() regardless of $props.language.
// tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
// cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
// (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
// portal handle re-renders the consumer fragment IN PLACE on caret move rather
// than remounting it each keystroke. NO compartment — a StateField is the
// idiomatic showTooltip source and there is no runtime prop to reconfigure it
// against (slot presence is decided once at mount).

// language is a convenience prop mapping to the ONE bundled language
// (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
// highlighting); consumers add other languages via :extensions (D-03). This
// FIXES the prior declared-but-ignored bug where buildState hard-coded
// javascript() regardless of $props.language.
const langExt = () => language === 'javascript' ? javascript() : [];

// theme resolution (G3): the two built-in strings map to oneDark / [];
// anything else is treated as a CM Extension (or Extension[]) and passed
// straight through the themeCompartment. The $watch(theme) reconfigure below
// covers extension themes live, identical to the string forms.
// theme resolution (G3): the two built-in strings map to oneDark / [];
// anything else is treated as a CM Extension (or Extension[]) and passed
// straight through the themeCompartment. The $watch(theme) reconfigure below
// covers extension themes live, identical to the string forms.
const themeExt = () => {
  const t = theme;
  if (t === 'dark') return oneDark;
  if (t === 'light' || t === '' || t == null) return [];
  // t is a CM Extension / Extension[] passthrough by this branch (the widened
  // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
  // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
  // accept it; the type-neutral targets strip types entirely.
  return t;
};

// placeholder ext only when a non-empty placeholder string is supplied.
// placeholder ext only when a non-empty placeholder string is supplied.
const phExt = () => placeholder ? placeholderExt(placeholder) : [];

// baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
// `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
// lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
// the manual trio would double those up). When off, keep the exact thin
// baseline the wrapper shipped before G1 (line numbers + history + default/
// history keymaps) → existing consumers stay byte-stable. Read at buildState
// time only — no compartment (see the basicSetup prop note).
// baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
// `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
// lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
// the manual trio would double those up). When off, keep the exact thin
// baseline the wrapper shipped before G1 (line numbers + history + default/
// history keymaps) → existing consumers stay byte-stable. Read at buildState
// time only — no compartment (see the basicSetup prop note).
const baselineExt = () => basicSetup ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])];

// buildState + the panel-slot wiring live INSIDE $onMount so the $portals.panel
// reference is bound in the mount scope. The per-target emitters scope the
// concrete portal helper inside the mount lifecycle (React useEffect / Lit
// firstUpdated / etc.); a top-level `panelExt` that references $portals would
// land out-of-scope of that helper and fail the bundled-leaf strict typecheck
// (TS2304 'portals' / TS2742). Keeping the $portals.panel use inside $onMount
// mirrors FullCalendar's portal pattern (its eventContent callbacks reference
// $portals only inside $onMount). buildState is only ever called here, so this
// is a behavior-preserving relocation. Compartments + langExt/themeExt/phExt
// stay top-level — the $watch reconfigures still reference them.
// Shared suppress-echo write helper. Both the $watch(value) consumer-driven
// reflect AND the $expose setValue verb route through this so a programmatic
// or prop-driven set doesn't ping-pong back through the model path. When the
// editor itself was the source of the change, the doc already matches `v`, so
// dispatching another transaction would mint a duplicate undo-history entry
// for no UI change.
const writeDoc = (v: any) => {
  if (!view) return;
  const current = view.state.doc.toString();
  const next = v ?? '';
  if (current === next) return;
  suppressEmit = true;
  try {
    view.dispatch({
      changes: {
        from: 0,
        to: current.length,
        insert: next
      }
    });
  } finally {
    suppressEmit = false;
  }
};

// Consumer-driven value writes: reflect into the live editor (echo-guarded).
// Imperative handle (Phase 21 $expose). The 12 editor verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets. Each guards
// the pre-mount/destroyed `view = null`. Collision-clear: none of the names
// collide with the props (value/language/theme/readOnly/height/placeholder/
// extensions/basicSetup/gutterLines/decorations) and there are no events (D-08).
//
// undo/redo/selectAll are the basic editor-command verbs a toolbar needs (history
// ships with basicSetup / the bundled `history()` extension); the @codemirror/
// commands functions are reached via the `cmCommands` namespace import so the
// public verb names don't self-shadow the imports. scrollToPos reveals a document
// position — it is NOT named `scrollIntoView`/`scrollTo` (both inherited
// HTMLElement methods → would shadow on the Lit leaf, the embla scrollTo lesson).
export function getView() {
  return view;
}
export function focus() {
  view?.focus();
}
export function getValue() {
  return view ? view.state.doc.toString() : '';
}
// replaceValue routes through the SAME suppress-echo guard as $watch(value).
// NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
// React auto-generate a `setValue` state setter, so a `setValue` $expose verb
// collides on the React target (ROZ524: "already declared" + recursive rewrite).
// Renamed to preserve the value-setter semantics collision-free across all 6
// targets. (Deviation from the locked D-06 name `setValue`.)
// replaceValue routes through the SAME suppress-echo guard as $watch(value).
// NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
// React auto-generate a `setValue` state setter, so a `setValue` $expose verb
// collides on the React target (ROZ524: "already declared" + recursive rewrite).
// Renamed to preserve the value-setter semantics collision-free across all 6
// targets. (Deviation from the locked D-06 name `setValue`.)
export function replaceValue(v: any) {
  writeDoc(v);
}
export function dispatch(tr: any) {
  view?.dispatch(tr);
}
export function insertText(text: any) {
  if (!view) return;
  const {
    from,
    to
  } = view.state.selection.main;
  view.dispatch({
    changes: {
      from,
      to,
      insert: text
    },
    userEvent: 'input.type'
  });
}
export function getSelection() {
  return view ? view.state.selection.main : null;
}
export function setSelection(range: any) {
  if (!view) return;
  const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
  view.dispatch({
    selection: sel
  });
}
export function undo() {
  if (view) cmCommands.undo(view);
}
export function redo() {
  if (view) cmCommands.redo(view);
}
export function selectAll() {
  if (view) cmCommands.selectAll(view);
}
// Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
// moves the caret but does not guarantee scroll; this dispatches the scroll
// effect. opts default centers the position vertically.
// Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
// moves the caret but does not guarantee scroll; this dispatches the scroll
// effect. opts default centers the position vertically.
export function scrollToPos(pos: any, opts: any) {
  if (!view) return;
  view.dispatch({
    effects: EditorView.scrollIntoView(pos, opts ?? {
      y: 'center'
    })
  });
}

interface ReactivePortalHandle {
  update(scope: unknown): void;
  dispose(): void;
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
    if (!panel) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-panel', '34cfda5a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: panel, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
    if (!topPanel) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: topPanel, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
    if (!tooltip) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
    const inst = mount(PortalHostReactive, {
      target: container,
      props: { snippet: tooltip, initialScope: scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return {
      update: (s: unknown): void => {
        (inst as unknown as { update(s: unknown): void }).update(s);
      },
      dispose: (): void => {
        unmount(inst as Parameters<typeof unmount>[0]);
        portalInstances.delete(inst as Record<string, unknown>);
      },
    };
  },
  gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
    if (!gutter) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
    const inst = mount(PortalHostReactive, {
      target: container,
      props: { snippet: gutter, initialScope: scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return {
      update: (s: unknown): void => {
        (inst as unknown as { update(s: unknown): void }).update(s);
      },
      dispose: (): void => {
        unmount(inst as Parameters<typeof unmount>[0]);
        portalInstances.delete(inst as Record<string, unknown>);
      },
    };
  },
  decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
    if (!decoration) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
    const inst = mount(PortalHostReactive, {
      target: container,
      props: { snippet: decoration, initialScope: scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return {
      update: (s: unknown): void => {
        (inst as unknown as { update(s: unknown): void }).update(s);
      },
      dispose: (): void => {
        unmount(inst as Parameters<typeof unmount>[0]);
        portalInstances.delete(inst as Record<string, unknown>);
      },
    };
  },
};
$effect(() => () => {
  for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
  portalInstances.clear();
});

onMount(() => {
  // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
  // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
  // consumer's framework-native fragment on Panel.mount() and the returned
  // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
  // doesn't fill the slot.
  // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
  // `mount() {}` methods) and the panel host element + view are captured in
  // plain `const`s. The object-method form gives each method its own `this`
  // scope, and the Lit emitter's component-field rewrite walks INTO that method
  // body and rewrites a closure-captured `view` reference to `this.view`
  // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
  // properties share the enclosing lexical scope, so the captured `panelView`
  // const resolves correctly on every target. CM6 calls `panel.mount()` /
  // `panel.destroy()` either way.
  const panelExt = () => {
    if (!panel) return [];
    return showPanel.of((panelView: any) => {
      const dom = document.createElement('div');
      dom.className = 'rozie-cm-panel';
      const scope = {
        view: panelView
      };
      let dispose: any = null;
      return {
        dom,
        top: false,
        mount: () => {
          dispose = portals.panel(dom, scope);
        },
        destroy: () => {
          dispose?.();
          dispose = null;
        }
      };
    });
  };

  // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
  // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
  // object-method `mount() {}` — the Lit field-rewrite caveat documented on
  // panelExt above applies identically), differing ONLY in `top: true` and the
  // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
  const topPanelExt = () => {
    if (!topPanel) return [];
    return showPanel.of((panelView: any) => {
      const dom = document.createElement('div');
      dom.className = 'rozie-cm-panel-top';
      const scope = {
        view: panelView
      };
      let dispose: any = null;
      return {
        dom,
        top: true,
        mount: () => {
          dispose = portals.topPanel(dom, scope);
        },
        destroy: () => {
          dispose?.();
          dispose = null;
        }
      };
    });
  };

  // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
  // cursor-anchored tooltip provided through the `showTooltip` facet via a
  // StateField that yields ONE Tooltip at the main selection head whenever the
  // `tooltip` slot is filled.
  //
  // UPDATE-IN-PLACE reconciliation (verified against the installed
  // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
  // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
  // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
  // old one's (`other.create == tip.create`); and when the whole showTooltip
  // facet INPUT is unchanged it skips matching entirely and calls update() on
  // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
  // (`stableTooltip`, stable `create`) and returning that SAME object from the
  // field's `update` while the head only moved. So the consumer fragment mounts
  // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
  // every caret move flows through TooltipView.update → handle.update(scope) —
  // re-rendering the fragment IN PLACE, never remounting it.
  const tooltipField = () => {
    if (!tooltip) return [];
    // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
    // the field's closure so create()/update()/destroy() share it across the
    // tooltip's lifetime.
    let handle: any = null;
    // Stable Tooltip object — its `create` reference never changes, so CM
    // reuses the TooltipView across caret moves (update-in-place, no remount).
    // NOTE: `create` is an ARROW-FUNCTION property and its param is named
    // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
    // panelExt: an object-method `create(view) {}` would get its own `this`,
    // and the Lit emitter's component-field rewrite walks into the body and
    // rewrites a `view`-named token (matching the top-level `let view`) to
    // `this.view`. An arrow property shares the enclosing scope, and the
    // non-colliding param name keeps the caret-view reference correct on every
    // target. CM calls `tooltip.create(view)` either way.
    const stableTooltip = {
      pos: 0,
      above: true,
      create: (tipView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-tooltip';
        return {
          dom,
          mount: () => {
            handle = portals.tooltip(dom, {
              view: tipView,
              pos: tipView.state.selection.main.head
            });
          },
          // Reactive in-place update — fired by CM on every ViewUpdate while the
          // tooltip view is reused. Re-renders the consumer fragment with the
          // fresh caret position; the fragment is NOT remounted (REQ — verified
          // empirically via the demo's mount/update counters).
          update: (u: any) => {
            handle?.update({
              view: u.view,
              pos: u.state.selection.main.head
            });
          },
          destroy: () => {
            handle?.dispose();
            handle = null;
          }
        };
      }
    };
    // NOTE: the StateField.update callback's first param is named `cur` (NOT the
    // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
    // local `value` binding into the prop-state ref (`_valueRef.current`) — it
    // walks into this callback and corrupts the field's accumulator
    // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
    // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
    // collision-free across all 6 targets.
    return StateField.define({
      create: (state: any) => ({
        ...stableTooltip,
        pos: state.selection.main.head
      }),
      update: (cur: any, tr: any) => {
        // Keep the SAME stable `create`; only the head moves. Reuse the existing
        // object when the head is unchanged so the facet input is identity-stable.
        const head = tr.state.selection.main.head;
        if (cur && cur.pos === head) return cur;
        return {
          ...stableTooltip,
          pos: head
        };
      },
      provide: (f: any) => showTooltip.from(f)
    });
  };

  // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
  // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
  // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
  // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
  // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
  // the TipTap nodeView multi-instance template: the GutterMarker class captures
  // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
  //
  // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
  // subclassing), but its per-marker state (`line`, the live portal handle) lives
  // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
  // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
  // class fields assigned only in the constructor (`this.line = …`) without a
  // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
  // the class through verbatim (a class-field type aid is an emitter concern, OUT
  // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
  // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
  // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
  // so `override` is unparseable — so the three bundled leaves relax
  // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
  // now match). The `view` param is named `mView` — the Lit field-rewrite walks
  // into a method body and rewrites a bare `view` token (matching the top-level
  // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
  const makeGutterExt = (gv: any) => {
    if (!gutter) return [];
    const makeGutterMarker = (line: any) => {
      let handle: any = null;
      return new class extends GutterMarker {
        toDOM(mView: any) {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-gutter-marker';
          handle = gv(dom, {
            line,
            view: mView
          });
          return dom;
        }
        destroy() {
          handle?.dispose();
          handle = null;
        }
      }();
    };
    // Recompute the marker RangeSet from `gutterLines` against the live doc —
    // one marker at the START of each in-range line. RangeSet.of REQUIRES the
    // ranges sorted by `from`, so sort the resolved positions.
    const buildMarkers = (mView: any) => {
      const doc = mView.state.doc;
      const ranges = [];
      for (const n of gutterLines as any) {
        if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
        ranges.push(makeGutterMarker(n).range(doc.line(n).from));
      }
      ranges.sort((a: any, b: any) => a.from - b.from);
      return RangeSet.of(ranges);
    };
    return gutterExt({
      class: 'rozie-cm-gutter',
      markers: (mView: any) => buildMarkers(mView)
    });
  };

  // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
  // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
  // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
  // portal handle PER VISIBLE widget. The decoration set is provided through a
  // compartment-wrapped facet so the `decorations` prop reconfigures it live.
  // The WidgetType class captures $portals.decoration, so it is defined inside
  // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
  const makeDecorationExt = (dv: any) => {
    // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
    // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
    // in the extensions array (via `decorationCompartment.of(...)`) makes
    // EditorState.create throw at runtime — the editor never mounts. Only the
    // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
    if (!decoration) return [];
    // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
    // but its per-widget state (`from`/`to`, the live portal handle) lives in
    // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
    // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
    // `this` fields trip TS2339; the overriding methods can't carry the TS-only
    // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
    // `noImplicitOverride`). No `eq` override is needed: the decoration set is
    // rebuilt from the prop on every reconfigure, so default reference-`eq`
    // (always "different") correctly remounts each widget instead of reusing stale
    // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
    const makeWidget = (from: any, to: any) => {
      let handle: any = null;
      return new class extends WidgetType {
        toDOM(dView: any) {
          const dom = document.createElement('span');
          dom.className = 'rozie-cm-decoration';
          handle = dv(dom, {
            from,
            to,
            view: dView
          });
          return dom;
        }
        destroy() {
          handle?.dispose();
          handle = null;
        }
        // Inline widgets must not be considered editable content.
        ignoreEvent() {
          return false;
        }
      }();
    };
    // Build the DecorationSet from `decorations` against the live doc. Each entry
    // is a point widget at `from` (side: 1 — after the position); out-of-range
    // offsets are clamped to the doc length and skipped if `from` is invalid.
    // Decoration.set REQUIRES the ranges sorted by `from`.
    const buildSet = (state: any) => {
      const len = state.doc.length;
      const ranges = [];
      for (const d of decorations as any) {
        if (!d || typeof d.from !== 'number') continue;
        const from = Math.max(0, Math.min(d.from, len));
        const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
        ranges.push(Decoration.widget({
          widget: makeWidget(from, to),
          side: 1
        }).range(from));
      }
      ranges.sort((a: any, b: any) => a.from - b.from);
      return Decoration.set(ranges);
    };
    // A StateField yields the DecorationSet and provides it to EditorView.
    // decorations. The set is rebuilt on every prop-driven reconfigure (the
    // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
    // and tracked across local doc edits via mapping so widget positions follow.
    return StateField.define({
      create: (state: any) => buildSet(state),
      update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
      provide: (f: any) => EditorView.decorations.from(f)
    });
  };

  // Bridge the mount-built factories to the top-level $watch reconfigures. Each
  // closes over the captured $portals helper so a prop change can rebuild the
  // extension without re-referencing $portals at top level.
  rebuildGutterExt = () => makeGutterExt(portals.gutter);
  rebuildDecorationExt = () => makeDecorationExt(portals.decoration);
  const buildState = (doc: any) => EditorState.create({
    doc,
    extensions: [...baselineExt(), langCompartment.of(langExt()), themeCompartment.of(themeExt()), readOnlyCompartment.of(EditorState.readOnly.of(readOnly)), placeholderCompartment.of(phExt()), panelCompartment.of(panelExt()), topPanelCompartment.of(topPanelExt()),
    // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
    // 2). Each lives in a compartment so its driving prop (gutterLines /
    // decorations) reconfigures live; the factory captures the per-target
    // $portals helper (gutter / decoration) here in the mount scope.
    gutterCompartment.of(rebuildGutterExt()), decorationCompartment.of(rebuildDecorationExt()),
    // tooltipField() returns a StateField extension (or [] when the slot is
    // unfilled); no compartment — it is a one-shot mount-time decision.
    tooltipField(), EditorView.updateListener.of((update: any) => {
      if (!update.docChanged) return;
      if (suppressEmit) return;
      // Push the new doc out through the model:true emit path. Consumers
      // bound via `r-model:value="$data.x"` receive the change.
      value = update.state.doc.toString();
    }),
    // Consumer extensions LAST so they win CM6's last-registered-wins facets.
    extensionsCompartment.of(extensions)]
  });
  view = new EditorView({
    state: buildState(value),
    parent: hostEl!
  });
  return () => view?.destroy();
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => value)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => writeDoc(v))(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => language)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
  if (!view) return;
  view.dispatch({
    effects: langCompartment.reconfigure(langExt())
  });
})(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => theme)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => {
  if (!view) return;
  view.dispatch({
    effects: themeCompartment.reconfigure(themeExt())
  });
})(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => readOnly)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
  if (!view) return;
  view.dispatch({
    effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
  });
})(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { (() => placeholder)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } (() => {
  if (!view) return;
  view.dispatch({
    effects: placeholderCompartment.reconfigure(phExt())
  });
})(); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => extensions)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => {
  if (!view) return;
  view.dispatch({
    effects: extensionsCompartment.reconfigure(v)
  });
})(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { (() => gutterLines)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } (() => {
  if (!view || !rebuildGutterExt) return;
  view.dispatch({
    effects: gutterCompartment.reconfigure(rebuildGutterExt())
  });
})(); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { (() => decorations)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } (() => {
  if (!view || !rebuildDecorationExt) return;
  view.dispatch({
    effects: decorationCompartment.reconfigure(rebuildDecorationExt())
  });
})(); }); });
</script>

<div class="rozie-codemirror" style:height={height + 'px'} data-rozie-s-34cfda5a><div class="cm-mount" bind:this={hostEl} data-rozie-s-34cfda5a></div></div>

<style>
:global {
  .rozie-codemirror[data-rozie-s-34cfda5a] {
    border: 1px solid rgba(0, 0, 0, 0.12);
    border-radius: 4px;
    overflow: hidden;
    font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
  }
  .cm-mount[data-rozie-s-34cfda5a] {
    height: 100%;
    width: 100%;
  }
}

:global {
  .rozie-codemirror .cm-editor {
      height: 100%;
      font-size: 13px;
    }
  .rozie-codemirror .cm-scroller {
      height: 100%;
    }
  .rozie-codemirror .rozie-cm-panel {
      padding: 2px 8px;
      border-top: 1px solid rgba(0, 0, 0, 0.12);
      font-size: 12px;
    }
  .rozie-codemirror .rozie-cm-panel-top {
      padding: 2px 8px;
      border-bottom: 1px solid rgba(0, 0, 0, 0.12);
      font-size: 12px;
    }
  .rozie-codemirror .rozie-cm-tooltip {
      padding: 2px 6px;
      font-size: 11px;
      background: #1a1a1a;
      color: #fff;
      border-radius: 3px;
      white-space: nowrap;
    }
  .rozie-codemirror .rozie-cm-gutter {
      min-width: 14px;
    }
  .rozie-codemirror .rozie-cm-gutter-marker {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      font-size: 11px;
      line-height: 1;
    }
  .rozie-codemirror .rozie-cm-decoration {
      display: inline-flex;
      align-items: center;
      vertical-align: text-bottom;
    }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, forwardRef, inject, input, model, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';

interface PanelCtx {
  $implicit: { view: any };
  view: any;
}

interface TopPanelCtx {
  $implicit: { view: any };
  view: any;
}

interface TooltipCtx {
  $implicit: { view: any; pos: any };
  view: any;
  pos: any;
}

interface GutterCtx {
  $implicit: { line: any; view: any };
  line: any;
  view: any;
}

interface DecorationCtx {
  $implicit: { from: any; to: any; view: any };
  from: any;
  to: any;
  view: any;
}

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

    <div class="rozie-codemirror" [style]="{ height: height() + 'px' }">
      <div class="cm-mount" #hostEl></div>
    </div>










    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    .rozie-codemirror {
      border: 1px solid rgba(0, 0, 0, 0.12);
      border-radius: 4px;
      overflow: hidden;
      font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
    }
    .cm-mount {
      height: 100%;
      width: 100%;
    }

    ::ng-deep .rozie-codemirror .cm-editor {
        height: 100%;
        font-size: 13px;
      }
    ::ng-deep .rozie-codemirror .cm-scroller {
        height: 100%;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-panel {
        padding: 2px 8px;
        border-top: 1px solid rgba(0, 0, 0, 0.12);
        font-size: 12px;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-panel-top {
        padding: 2px 8px;
        border-bottom: 1px solid rgba(0, 0, 0, 0.12);
        font-size: 12px;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-tooltip {
        padding: 2px 6px;
        font-size: 11px;
        background: #1a1a1a;
        color: #fff;
        border-radius: 3px;
        white-space: nowrap;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-gutter {
        min-width: 14px;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-gutter-marker {
        display: flex;
        align-items: center;
        justify-content: center;
        height: 100%;
        font-size: 11px;
        line-height: 1;
      }
    ::ng-deep .rozie-codemirror .rozie-cm-decoration {
        display: inline-flex;
        align-items: center;
        vertical-align: text-bottom;
      }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CodeMirror),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class CodeMirror {
  /**
   * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
   * @example
   * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
   */
  value = model<string>('');
  /**
   * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
   */
  language = input<string>('javascript');
  /**
   * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
   */
  theme = input<unknown>('light');
  /**
   * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
   */
  readOnly = input<boolean>(false);
  /**
   * Editor height in pixels, applied to the wrapper's host box.
   */
  height = input<number>(240);
  /**
   * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
   */
  placeholder = input<string>('');
  /**
   * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
   */
  extensions = input<any[]>((() => [])());
  /**
   * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
   */
  basicSetup = input<boolean>(false);
  /**
   * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
   */
  gutterLines = input<any[]>((() => [])());
  /**
   * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
   */
  decorations = input<any[]>((() => [])());
  hostEl = viewChild<ElementRef<HTMLDivElement>>('hostEl');
  @ContentChild('panel', { read: TemplateRef }) panelTpl?: TemplateRef<PanelCtx>;
  @ContentChild('topPanel', { read: TemplateRef }) topPanelTpl?: TemplateRef<TopPanelCtx>;
  @ContentChild('tooltip', { read: TemplateRef }) tooltipTpl?: TemplateRef<TooltipCtx>;
  @ContentChild('gutter', { read: TemplateRef }) gutterTpl?: TemplateRef<GutterCtx>;
  @ContentChild('decoration', { read: TemplateRef }) decorationTpl?: TemplateRef<DecorationCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _panelTpl = contentChild('panel', { read: TemplateRef });
  private _topPanelTpl = contentChild('topPanel', { read: TemplateRef });
  private _tooltipTpl = contentChild('tooltip', { read: TemplateRef });
  private _gutterTpl = contentChild('gutter', { read: TemplateRef });
  private _decorationTpl = contentChild('decoration', { read: TemplateRef });
  private __rozieDestroyRef = inject(DestroyRef);
  private __rozieWatchInitial_0 = true;
  private __rozieWatchInitial_1 = true;
  private __rozieWatchInitial_2 = true;
  private __rozieWatchInitial_3 = true;
  private __rozieWatchInitial_4 = true;
  private __rozieWatchInitial_5 = true;
  private __rozieWatchInitial_6 = true;
  private __rozieWatchInitial_7 = true;

  constructor() {
    effect(() => { const __watchVal = (() => this.value())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => this.writeDoc(v))(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.language())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.langCompartment.reconfigure(this.langExt())
      });
    })(); }); });
    effect(() => { const __watchVal = (() => this.theme())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.themeCompartment.reconfigure(this.themeExt())
      });
    })(); }); });
    effect(() => { const __watchVal = (() => this.readOnly())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
      });
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.placeholder())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.placeholderCompartment.reconfigure(this.phExt())
      });
    })(); }); });
    effect(() => { const __watchVal = (() => this.extensions())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.extensionsCompartment.reconfigure(v)
      });
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.gutterLines())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } (() => {
      if (!this.view || !this.rebuildGutterExt) return;
      this.view.dispatch({
        effects: this.gutterCompartment.reconfigure(this.rebuildGutterExt())
      });
    })(); }); });
    effect(() => { const __watchVal = (() => this.decorations())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } (() => {
      if (!this.view || !this.rebuildDecorationExt) return;
      this.view.dispatch({
        effects: this.decorationCompartment.reconfigure(this.rebuildDecorationExt())
      });
    })(); }); });
  }

  ngAfterViewInit() {
    interface ReactivePortalHandle {
      update(scope: unknown): void;
      dispose(): void;
    }
    const portals = {
      panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
        const tpl = this._panelTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-panel', '34cfda5a');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return () => {
          view.destroy();
          this._portalViews.delete(view as EmbeddedViewRef<unknown>);
        };
      },
      topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
        const tpl = this._topPanelTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return () => {
          view.destroy();
          this._portalViews.delete(view as EmbeddedViewRef<unknown>);
        };
      },
      tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
        const tpl = this._tooltipTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return {
          update: (s: unknown): void => {
            Object.assign(view.context as object, s as object);
            view.detectChanges();
          },
          dispose: (): void => {
            view.destroy();
            this._portalViews.delete(view as EmbeddedViewRef<unknown>);
          },
        };
      },
      gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
        const tpl = this._gutterTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return {
          update: (s: unknown): void => {
            Object.assign(view.context as object, s as object);
            view.detectChanges();
          },
          dispose: (): void => {
            view.destroy();
            this._portalViews.delete(view as EmbeddedViewRef<unknown>);
          },
        };
      },
      decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
        const tpl = this._decorationTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return {
          update: (s: unknown): void => {
            Object.assign(view.context as object, s as object);
            view.detectChanges();
          },
          dispose: (): void => {
            view.destroy();
            this._portalViews.delete(view as EmbeddedViewRef<unknown>);
          },
        };
      },
    };
    // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
    // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
    // consumer's framework-native fragment on Panel.mount() and the returned
    // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
    // doesn't fill the slot.
    // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
    // `mount() {}` methods) and the panel host element + view are captured in
    // plain `const`s. The object-method form gives each method its own `this`
    // scope, and the Lit emitter's component-field rewrite walks INTO that method
    // body and rewrites a closure-captured `view` reference to `this.view`
    // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
    // properties share the enclosing lexical scope, so the captured `panelView`
    // const resolves correctly on every target. CM6 calls `panel.mount()` /
    // `panel.destroy()` either way.
    const panelExt = () => {
      if (!(this.panelTpl ?? this.templates()?.['panel'])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: false,
          mount: () => {
            dispose = portals.panel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    const topPanelExt = () => {
      if (!(this.topPanelTpl ?? this.templates()?.['topPanel'])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel-top';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: true,
          mount: () => {
            dispose = portals.topPanel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    const tooltipField = () => {
      if (!(this.tooltipTpl ?? this.templates()?.['tooltip'])) return [];
      // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
      // the field's closure so create()/update()/destroy() share it across the
      // tooltip's lifetime.
      let handle: any = null;
      // Stable Tooltip object — its `create` reference never changes, so CM
      // reuses the TooltipView across caret moves (update-in-place, no remount).
      // NOTE: `create` is an ARROW-FUNCTION property and its param is named
      // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
      // panelExt: an object-method `create(view) {}` would get its own `this`,
      // and the Lit emitter's component-field rewrite walks into the body and
      // rewrites a `view`-named token (matching the top-level `let view`) to
      // `this.view`. An arrow property shares the enclosing scope, and the
      // non-colliding param name keeps the caret-view reference correct on every
      // target. CM calls `tooltip.create(view)` either way.
      const stableTooltip = {
        pos: 0,
        above: true,
        create: (tipView: any) => {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-tooltip';
          return {
            dom,
            mount: () => {
              handle = portals.tooltip(dom, {
                view: tipView,
                pos: tipView.state.selection.main.head
              });
            },
            // Reactive in-place update — fired by CM on every ViewUpdate while the
            // tooltip view is reused. Re-renders the consumer fragment with the
            // fresh caret position; the fragment is NOT remounted (REQ — verified
            // empirically via the demo's mount/update counters).
            update: (u: any) => {
              handle?.update({
                view: u.view,
                pos: u.state.selection.main.head
              });
            },
            destroy: () => {
              handle?.dispose();
              handle = null;
            }
          };
        }
      };
      // NOTE: the StateField.update callback's first param is named `cur` (NOT the
      // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
      // local `value` binding into the prop-state ref (`_valueRef.current`) — it
      // walks into this callback and corrupts the field's accumulator
      // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
      // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
      // collision-free across all 6 targets.
      return StateField.define({
        create: (state: any) => ({
          ...stableTooltip,
          pos: state.selection.main.head
        }),
        update: (cur: any, tr: any) => {
          // Keep the SAME stable `create`; only the head moves. Reuse the existing
          // object when the head is unchanged so the facet input is identity-stable.
          const head = tr.state.selection.main.head;
          if (cur && cur.pos === head) return cur;
          return {
            ...stableTooltip,
            pos: head
          };
        },
        provide: (f: any) => showTooltip.from(f)
      });
    };

    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    const makeGutterExt = (gv: any) => {
      if (!(this.gutterTpl ?? this.templates()?.['gutter'])) return [];
      const makeGutterMarker = (line: any) => {
        let handle: any = null;
        return new class extends GutterMarker {
          toDOM(mView: any) {
            const dom = document.createElement('div');
            dom.className = 'rozie-cm-gutter-marker';
            handle = gv(dom, {
              line,
              view: mView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
        }();
      };
      // Recompute the marker RangeSet from `gutterLines` against the live doc —
      // one marker at the START of each in-range line. RangeSet.of REQUIRES the
      // ranges sorted by `from`, so sort the resolved positions.
      const buildMarkers = (mView: any): any => {
        const doc = mView.state.doc;
        const ranges = [];
        for (const n of this.gutterLines() as any) {
          if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
          ranges.push(makeGutterMarker(n).range(doc.line(n).from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return RangeSet.of(ranges);
      };
      return gutterExt({
        class: 'rozie-cm-gutter',
        markers: (mView: any) => buildMarkers(mView)
      });
    };

    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    const makeDecorationExt = (dv: any) => {
      // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
      // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
      // in the extensions array (via `decorationCompartment.of(...)`) makes
      // EditorState.create throw at runtime — the editor never mounts. Only the
      // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
      if (!(this.decorationTpl ?? this.templates()?.['decoration'])) return [];
      // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
      // but its per-widget state (`from`/`to`, the live portal handle) lives in
      // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
      // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
      // `this` fields trip TS2339; the overriding methods can't carry the TS-only
      // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
      // `noImplicitOverride`). No `eq` override is needed: the decoration set is
      // rebuilt from the prop on every reconfigure, so default reference-`eq`
      // (always "different") correctly remounts each widget instead of reusing stale
      // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
      const makeWidget = (from: any, to: any) => {
        let handle: any = null;
        return new class extends WidgetType {
          toDOM(dView: any) {
            const dom = document.createElement('span');
            dom.className = 'rozie-cm-decoration';
            handle = dv(dom, {
              from,
              to,
              view: dView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
          // Inline widgets must not be considered editable content.
          ignoreEvent() {
            return false;
          }
        }();
      };
      // Build the DecorationSet from `decorations` against the live doc. Each entry
      // is a point widget at `from` (side: 1 — after the position); out-of-range
      // offsets are clamped to the doc length and skipped if `from` is invalid.
      // Decoration.set REQUIRES the ranges sorted by `from`.
      const buildSet = (state: any) => {
        const len = state.doc.length;
        const ranges = [];
        for (const d of this.decorations() as any) {
          if (!d || typeof d.from !== 'number') continue;
          const from = Math.max(0, Math.min(d.from, len));
          const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
          ranges.push(Decoration.widget({
            widget: makeWidget(from, to),
            side: 1
          }).range(from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return Decoration.set(ranges);
      };
      // A StateField yields the DecorationSet and provides it to EditorView.
      // decorations. The set is rebuilt on every prop-driven reconfigure (the
      // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
      // and tracked across local doc edits via mapping so widget positions follow.
      return StateField.define({
        create: (state: any) => buildSet(state),
        update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
        provide: (f: any) => EditorView.decorations.from(f)
      });
    };

    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    this.rebuildGutterExt = () => makeGutterExt(portals.gutter);
    this.rebuildDecorationExt = () => makeDecorationExt(portals.decoration);
    const buildState = (doc: any) => EditorState.create({
      doc,
      extensions: [...this.baselineExt(), this.langCompartment.of(this.langExt()), this.themeCompartment.of(this.themeExt()), this.readOnlyCompartment.of(EditorState.readOnly.of(this.readOnly())), this.placeholderCompartment.of(this.phExt()), this.panelCompartment.of(panelExt()), this.topPanelCompartment.of(topPanelExt()),
      // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
      // 2). Each lives in a compartment so its driving prop (gutterLines /
      // decorations) reconfigures live; the factory captures the per-target
      // $portals helper (gutter / decoration) here in the mount scope.
      this.gutterCompartment.of(this.rebuildGutterExt()), this.decorationCompartment.of(this.rebuildDecorationExt()),
      // tooltipField() returns a StateField extension (or [] when the slot is
      // unfilled); no compartment — it is a one-shot mount-time decision.
      tooltipField(), EditorView.updateListener.of((update: any) => {
        if (!update.docChanged) return;
        if (this.suppressEmit) return;
        // Push the new doc out through the model:true emit path. Consumers
        // bound via `r-model:value="$data.x"` receive the change.
        this.value.set(update.state.doc.toString()), this.__rozieCvaOnChange(update.state.doc.toString());
      }),
      // Consumer extensions LAST so they win CM6's last-registered-wins facets.
      this.extensionsCompartment.of(this.extensions())]
    });
    this.view = new EditorView({
      state: buildState(this.value()),
      parent: this.hostEl()!.nativeElement
    });
    this.__rozieDestroyRef.onDestroy(() => this.view?.destroy());
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  view: any = null;
  suppressEmit = false;
  langCompartment = new Compartment();
  themeCompartment = new Compartment();
  readOnlyCompartment = new Compartment();
  placeholderCompartment = new Compartment();
  extensionsCompartment = new Compartment();
  panelCompartment = new Compartment();
  topPanelCompartment = new Compartment();
  gutterCompartment = new Compartment();
  decorationCompartment = new Compartment();
  rebuildGutterExt: any = null;
  rebuildDecorationExt: any = null;
  langExt = (): any => this.language() === 'javascript' ? javascript() : [];
  themeExt = (): any => {
    const t = this.theme();
    if (t === 'dark') return oneDark;
    if (t === 'light' || t === '' || t == null) return [];
    // t is a CM Extension / Extension[] passthrough by this branch (the widened
    // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
    // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
    // accept it; the type-neutral targets strip types entirely.
    return t;
  };
  phExt = (): any => this.placeholder() ? placeholderExt(this.placeholder()) : [];
  baselineExt = () => this.basicSetup() ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])];
  writeDoc = (v: any) => {
    if (!this.view) return;
    const current = this.view.state.doc.toString();
    const next = v ?? '';
    if (current === next) return;
    this.suppressEmit = true;
    try {
      this.view.dispatch({
        changes: {
          from: 0,
          to: current.length,
          insert: next
        }
      });
    } finally {
      this.suppressEmit = false;
    }
  };
  getView = () => {
    return this.view;
  };
  focus = () => {
    this.view?.focus();
  };
  getValue = () => {
    return this.view ? this.view.state.doc.toString() : '';
  };
  replaceValue = (v: any) => {
    this.writeDoc(v);
  };
  dispatch = (tr: any) => {
    this.view?.dispatch(tr);
  };
  insertText = (text: any) => {
    if (!this.view) return;
    const {
      from,
      to
    } = this.view.state.selection.main;
    this.view.dispatch({
      changes: {
        from,
        to,
        insert: text
      },
      userEvent: 'input.type'
    });
  };
  getSelection = () => {
    return this.view ? this.view.state.selection.main : null;
  };
  setSelection = (range: any) => {
    if (!this.view) return;
    const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
    this.view.dispatch({
      selection: sel
    });
  };
  undo = () => {
    if (this.view) cmCommands.undo(this.view);
  };
  redo = () => {
    if (this.view) cmCommands.redo(this.view);
  };
  selectAll = () => {
    if (this.view) cmCommands.selectAll(this.view);
  };
  scrollToPos = (pos: any, opts: any) => {
    if (!this.view) return;
    this.view.dispatch({
      effects: EditorView.scrollIntoView(pos, opts ?? {
        y: 'center'
      })
    });
  };

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

  writeValue(v: string | null): void {
    this.value.set(v ?? '');
  }
  registerOnChange(fn: (v: string) => 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: CodeMirror,
    _ctx: unknown,
  ): _ctx is PanelCtx | TopPanelCtx | TooltipCtx | GutterCtx | DecorationCtx {
    return true;
  }
}

export default CodeMirror;
tsx
import type { JSX } from 'solid-js';
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';

__rozieInjectStyle('CodeMirror-34cfda5a', `.rozie-codemirror[data-rozie-s-34cfda5a] {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 4px;
  overflow: hidden;
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}
.cm-mount[data-rozie-s-34cfda5a] {
  height: 100%;
  width: 100%;
}
.rozie-codemirror .cm-editor {
    height: 100%;
    font-size: 13px;
  }
.rozie-codemirror .cm-scroller {
    height: 100%;
  }
.rozie-codemirror .rozie-cm-panel {
    padding: 2px 8px;
    border-top: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-panel-top {
    padding: 2px 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-tooltip {
    padding: 2px 6px;
    font-size: 11px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 3px;
    white-space: nowrap;
  }
.rozie-codemirror .rozie-cm-gutter {
    min-width: 14px;
  }
.rozie-codemirror .rozie-cm-gutter-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    font-size: 11px;
    line-height: 1;
  }
.rozie-codemirror .rozie-cm-decoration {
    display: inline-flex;
    align-items: center;
    vertical-align: text-bottom;
  }`);

interface PanelSlotCtx { view: any; }

interface TopPanelSlotCtx { view: any; }

interface TooltipSlotCtx { view: any; pos: any; }

interface GutterSlotCtx { line: any; view: any; }

interface DecorationSlotCtx { from: any; to: any; view: any; }

interface CodeMirrorProps {
  /**
   * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
   * @example
   * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
   */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /**
   * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
   */
  language?: string;
  /**
   * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
   */
  theme?: unknown;
  /**
   * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
   */
  readOnly?: boolean;
  /**
   * Editor height in pixels, applied to the wrapper's host box.
   */
  height?: number;
  /**
   * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
   */
  placeholder?: string;
  /**
   * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
   */
  extensions?: any[];
  /**
   * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
   */
  basicSetup?: boolean;
  /**
   * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
   */
  gutterLines?: any[];
  /**
   * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
   */
  decorations?: any[];
  panelSlot?: (ctx: PanelSlotCtx) => JSX.Element;
  topPanelSlot?: (ctx: TopPanelSlotCtx) => JSX.Element;
  tooltipSlot?: (ctx: () => TooltipSlotCtx) => JSX.Element;
  gutterSlot?: (ctx: () => GutterSlotCtx) => JSX.Element;
  decorationSlot?: (ctx: () => DecorationSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: CodeMirrorHandle) => void;
}

export interface CodeMirrorHandle {
  getView: (...args: any[]) => any;
  focus: (...args: any[]) => any;
  getValue: (...args: any[]) => any;
  replaceValue: (...args: any[]) => any;
  dispatch: (...args: any[]) => any;
  insertText: (...args: any[]) => any;
  getSelection: (...args: any[]) => any;
  setSelection: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  selectAll: (...args: any[]) => any;
  scrollToPos: (...args: any[]) => any;
}

export default function CodeMirror(_props: CodeMirrorProps): JSX.Element {
  const _merged = mergeProps({ language: 'javascript', theme: 'light', readOnly: false, height: 240, placeholder: '', extensions: (() => [])(), basicSetup: false, gutterLines: (() => [])(), decorations: (() => [])() }, _props);
  const [local, attrs] = splitProps(_merged, ['value', 'language', 'theme', 'readOnly', 'height', 'placeholder', 'extensions', 'basicSetup', 'gutterLines', 'decorations', 'ref']);
  onMount(() => { local.ref?.({ getView, focus, getValue, replaceValue, dispatch, insertText, getSelection, setSelection, undo, redo, selectAll, scrollToPos }); });

  const [value, setValue] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'value', '');
  interface ReactivePortalHandle {
    update(scope: unknown): void;
    dispose(): void;
  }
  const portalDisposers = new Set<() => void>();
  const portals = {
    panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
      const slot = _props.panelSlot ?? _props.slots?.['panel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-panel', '34cfda5a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
      const slot = _props.topPanelSlot ?? _props.slots?.['topPanel'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
      const slot = _props.tooltipSlot ?? _props.slots?.['tooltip'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
      const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
      const dispose = render(() => slot(scopeSig as unknown as (() => { view: unknown; pos: unknown })), container);
      portalDisposers.add(dispose);
      return {
        update: (s: unknown): void => {
          setScopeSig(s);
        },
        dispose: (): void => {
          dispose();
          portalDisposers.delete(dispose);
        },
      };
    },
    gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
      const slot = _props.gutterSlot ?? _props.slots?.['gutter'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
      const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
      const dispose = render(() => slot(scopeSig as unknown as (() => { line: unknown; view: unknown })), container);
      portalDisposers.add(dispose);
      return {
        update: (s: unknown): void => {
          setScopeSig(s);
        },
        dispose: (): void => {
          dispose();
          portalDisposers.delete(dispose);
        },
      };
    },
    decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
      const slot = _props.decorationSlot ?? _props.slots?.['decoration'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
      const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
      const dispose = render(() => slot(scopeSig as unknown as (() => { from: unknown; to: unknown; view: unknown })), container);
      portalDisposers.add(dispose);
      return {
        update: (s: unknown): void => {
          setScopeSig(s);
        },
        dispose: (): void => {
          dispose();
          portalDisposers.delete(dispose);
        },
      };
    },
  };
  onCleanup(() => {
    for (const dispose of portalDisposers) dispose();
    portalDisposers.clear();
  });
  onMount(() => {
    const _cleanup = (() => {
    // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
    // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
    // consumer's framework-native fragment on Panel.mount() and the returned
    // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
    // doesn't fill the slot.
    // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
    // `mount() {}` methods) and the panel host element + view are captured in
    // plain `const`s. The object-method form gives each method its own `this`
    // scope, and the Lit emitter's component-field rewrite walks INTO that method
    // body and rewrites a closure-captured `view` reference to `this.view`
    // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
    // properties share the enclosing lexical scope, so the captured `panelView`
    // const resolves correctly on every target. CM6 calls `panel.mount()` /
    // `panel.destroy()` either way.
    const panelExt = () => {
      if (!(_props.panelSlot ?? _props.slots?.["panel"])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: false,
          mount: () => {
            dispose = portals.panel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    const topPanelExt = () => {
      if (!(_props.topPanelSlot ?? _props.slots?.["topPanel"])) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel-top';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: true,
          mount: () => {
            dispose = portals.topPanel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    const tooltipField = () => {
      if (!(_props.tooltipSlot ?? _props.slots?.["tooltip"])) return [];
      // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
      // the field's closure so create()/update()/destroy() share it across the
      // tooltip's lifetime.
      let handle: any = null;
      // Stable Tooltip object — its `create` reference never changes, so CM
      // reuses the TooltipView across caret moves (update-in-place, no remount).
      // NOTE: `create` is an ARROW-FUNCTION property and its param is named
      // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
      // panelExt: an object-method `create(view) {}` would get its own `this`,
      // and the Lit emitter's component-field rewrite walks into the body and
      // rewrites a `view`-named token (matching the top-level `let view`) to
      // `this.view`. An arrow property shares the enclosing scope, and the
      // non-colliding param name keeps the caret-view reference correct on every
      // target. CM calls `tooltip.create(view)` either way.
      const stableTooltip = {
        pos: 0,
        above: true,
        create: (tipView: any) => {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-tooltip';
          return {
            dom,
            mount: () => {
              handle = portals.tooltip(dom, {
                view: tipView,
                pos: tipView.state.selection.main.head
              });
            },
            // Reactive in-place update — fired by CM on every ViewUpdate while the
            // tooltip view is reused. Re-renders the consumer fragment with the
            // fresh caret position; the fragment is NOT remounted (REQ — verified
            // empirically via the demo's mount/update counters).
            update: (u: any) => {
              handle?.update({
                view: u.view,
                pos: u.state.selection.main.head
              });
            },
            destroy: () => {
              handle?.dispose();
              handle = null;
            }
          };
        }
      };
      // NOTE: the StateField.update callback's first param is named `cur` (NOT the
      // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
      // local `value` binding into the prop-state ref (`_valueRef.current`) — it
      // walks into this callback and corrupts the field's accumulator
      // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
      // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
      // collision-free across all 6 targets.
      return StateField.define({
        create: (state: any) => ({
          ...stableTooltip,
          pos: state.selection.main.head
        }),
        update: (cur: any, tr: any) => {
          // Keep the SAME stable `create`; only the head moves. Reuse the existing
          // object when the head is unchanged so the facet input is identity-stable.
          const head = tr.state.selection.main.head;
          if (cur && cur.pos === head) return cur;
          return {
            ...stableTooltip,
            pos: head
          };
        },
        provide: (f: any) => showTooltip.from(f)
      });
    };

    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    const makeGutterExt = (gv: any) => {
      if (!(_props.gutterSlot ?? _props.slots?.["gutter"])) return [];
      const makeGutterMarker = (line: any) => {
        let handle: any = null;
        return new class extends GutterMarker {
          toDOM(mView: any) {
            const dom = document.createElement('div');
            dom.className = 'rozie-cm-gutter-marker';
            handle = gv(dom, {
              line,
              view: mView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
        }();
      };
      // Recompute the marker RangeSet from `gutterLines` against the live doc —
      // one marker at the START of each in-range line. RangeSet.of REQUIRES the
      // ranges sorted by `from`, so sort the resolved positions.
      const buildMarkers = (mView: any): any => {
        const doc = mView.state.doc;
        const ranges = [];
        for (const n of local.gutterLines as any) {
          if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
          ranges.push(makeGutterMarker(n).range(doc.line(n).from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return RangeSet.of(ranges);
      };
      return gutterExt({
        class: 'rozie-cm-gutter',
        markers: (mView: any) => buildMarkers(mView)
      });
    };

    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    const makeDecorationExt = (dv: any) => {
      // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
      // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
      // in the extensions array (via `decorationCompartment.of(...)`) makes
      // EditorState.create throw at runtime — the editor never mounts. Only the
      // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
      if (!(_props.decorationSlot ?? _props.slots?.["decoration"])) return [];
      // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
      // but its per-widget state (`from`/`to`, the live portal handle) lives in
      // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
      // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
      // `this` fields trip TS2339; the overriding methods can't carry the TS-only
      // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
      // `noImplicitOverride`). No `eq` override is needed: the decoration set is
      // rebuilt from the prop on every reconfigure, so default reference-`eq`
      // (always "different") correctly remounts each widget instead of reusing stale
      // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
      const makeWidget = (from: any, to: any) => {
        let handle: any = null;
        return new class extends WidgetType {
          toDOM(dView: any) {
            const dom = document.createElement('span');
            dom.className = 'rozie-cm-decoration';
            handle = dv(dom, {
              from,
              to,
              view: dView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
          // Inline widgets must not be considered editable content.
          ignoreEvent() {
            return false;
          }
        }();
      };
      // Build the DecorationSet from `decorations` against the live doc. Each entry
      // is a point widget at `from` (side: 1 — after the position); out-of-range
      // offsets are clamped to the doc length and skipped if `from` is invalid.
      // Decoration.set REQUIRES the ranges sorted by `from`.
      const buildSet = (state: any) => {
        const len = state.doc.length;
        const ranges = [];
        for (const d of local.decorations as any) {
          if (!d || typeof d.from !== 'number') continue;
          const from = Math.max(0, Math.min(d.from, len));
          const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
          ranges.push(Decoration.widget({
            widget: makeWidget(from, to),
            side: 1
          }).range(from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return Decoration.set(ranges);
      };
      // A StateField yields the DecorationSet and provides it to EditorView.
      // decorations. The set is rebuilt on every prop-driven reconfigure (the
      // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
      // and tracked across local doc edits via mapping so widget positions follow.
      return StateField.define({
        create: (state: any) => buildSet(state),
        update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
        provide: (f: any) => EditorView.decorations.from(f)
      });
    };

    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    rebuildGutterExt = () => makeGutterExt(portals.gutter);
    rebuildDecorationExt = () => makeDecorationExt(portals.decoration);
    const buildState = (doc: any) => EditorState.create({
      doc,
      extensions: [...baselineExt(), langCompartment.of(langExt()), themeCompartment.of(themeExt()), readOnlyCompartment.of(EditorState.readOnly.of(local.readOnly)), placeholderCompartment.of(phExt()), panelCompartment.of(panelExt()), topPanelCompartment.of(topPanelExt()),
      // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
      // 2). Each lives in a compartment so its driving prop (gutterLines /
      // decorations) reconfigures live; the factory captures the per-target
      // $portals helper (gutter / decoration) here in the mount scope.
      gutterCompartment.of(rebuildGutterExt()), decorationCompartment.of(rebuildDecorationExt()),
      // tooltipField() returns a StateField extension (or [] when the slot is
      // unfilled); no compartment — it is a one-shot mount-time decision.
      tooltipField(), EditorView.updateListener.of((update: any) => {
        if (!update.docChanged) return;
        if (suppressEmit) return;
        // Push the new doc out through the model:true emit path. Consumers
        // bound via `r-model:value="$data.x"` receive the change.
        setValue(update.state.doc.toString());
      }),
      // Consumer extensions LAST so they win CM6's last-registered-wins facets.
      extensionsCompartment.of(local.extensions)]
    });
    view = new EditorView({
      state: buildState(value()),
      parent: hostElRef
    });
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => view?.destroy());
  });
  createEffect(on(() => (() => value())(), (v) => untrack(() => ((v: any) => writeDoc(v))(v)), { defer: true }));
  createEffect(on(() => (() => local.language)(), (v) => untrack(() => (() => {
    if (!view) return;
    view.dispatch({
      effects: langCompartment.reconfigure(langExt())
    });
  })()), { defer: true }));
  createEffect(on(() => (() => local.theme)(), (v) => untrack(() => (() => {
    if (!view) return;
    view.dispatch({
      effects: themeCompartment.reconfigure(themeExt())
    });
  })()), { defer: true }));
  createEffect(on(() => (() => local.readOnly)(), (v) => untrack(() => ((v: any) => {
    if (!view) return;
    view.dispatch({
      effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
    });
  })(v)), { defer: true }));
  createEffect(on(() => (() => local.placeholder)(), (v) => untrack(() => (() => {
    if (!view) return;
    view.dispatch({
      effects: placeholderCompartment.reconfigure(phExt())
    });
  })()), { defer: true }));
  createEffect(on(() => (() => local.extensions)(), (v) => untrack(() => ((v: any) => {
    if (!view) return;
    view.dispatch({
      effects: extensionsCompartment.reconfigure(v)
    });
  })(v)), { defer: true }));
  createEffect(on(() => (() => local.gutterLines)(), (v) => untrack(() => (() => {
    if (!view || !rebuildGutterExt) return;
    view.dispatch({
      effects: gutterCompartment.reconfigure(rebuildGutterExt())
    });
  })()), { defer: true }));
  createEffect(on(() => (() => local.decorations)(), (v) => untrack(() => (() => {
    if (!view || !rebuildDecorationExt) return;
    view.dispatch({
      effects: decorationCompartment.reconfigure(rebuildDecorationExt())
    });
  })()), { defer: true }));
  let hostElRef: HTMLElement | null = null;

  let view: any = null;

  // CodeMirror's updateListener fires on EVERY transaction, including our own
  // $watch-driven dispatch when the consumer changes `value`. Without a guard
  // the wrapper would emit its own dispatch back through the model path on
  // the next tick — a slow ping-pong loop that doesn't crash but eats RAFs.
  let suppressEmit = false;

  // Compartments let us swap individual extensions at runtime via
  // `view.dispatch({ effects: compartment.reconfigure(newExt) })` without
  // rebuilding the entire EditorState. Each runtime-updatable prop gets one so
  // prop changes don't lose cursor/history/scroll position.
  const langCompartment = new Compartment();
  const themeCompartment = new Compartment();
  const readOnlyCompartment = new Compartment();
  const placeholderCompartment = new Compartment();
  const extensionsCompartment = new Compartment();
  const panelCompartment = new Compartment();
  // topPanel is the top-docked sibling of `panel` — a SECOND mount-once portal
  // slot (G5 wave 1) wired through the same `showPanel` facet with `top: true`.
  const topPanelCompartment = new Compartment();
  // gutter / decoration are the REACTIVE MULTI-INSTANCE portal slots (G5 wave 2) —
  // one portal handle per visible marker/widget (the TipTap nodeView template).
  // Each owns a compartment so its driving prop (`gutterLines` / `decorations`)
  // reconfigures the marked lines / decorated ranges LIVE with no remount, like
  // every other runtime-updatable prop. The GutterMarker/WidgetType classes that
  // capture $portals.gutter / $portals.decoration are built INSIDE $onMount (a
  // top-level $portals reference fails the bundled-leaf strict typecheck — the
  // panel/tooltip/nodeView discipline), so these compartments are filled from
  // factories invoked in the mount body.
  const gutterCompartment = new Compartment();
  const decorationCompartment = new Compartment();
  // The gutter / decoration extension FACTORIES capture the per-target $portals
  // helper, so they MUST be built inside $onMount (a top-level $portals reference
  // fails the bundled-leaf strict typecheck). But the gutterLines / decorations
  // $watch reconfigures are top-level and need to rebuild the extension on prop
  // change. Bridge with these component-scope `let`s: $onMount assigns each to its
  // mount-built factory; the $watch closures call through them (no-op before mount
  // or when the slot is unfilled). COMPONENT-scope (not $onMount-local) so the
  // top-level $watch can reach them — the same hoist the TipTap toolbarDispose
  // uses for a mount-built handle referenced from outside the mount body.
  let rebuildGutterExt: any = null;
  let rebuildDecorationExt: any = null;
  // tooltip is CodeMirror's FIRST REACTIVE portal slot (G5 wave 1) — a
  // cursor-anchored tooltip via the `showTooltip` facet. Driven by a StateField
  // (`tooltipField`, built inside $onMount) so it tracks the caret; the reactive
  // portal handle re-renders the consumer fragment IN PLACE on caret move rather
  // than remounting it each keystroke. NO compartment — a StateField is the
  // idiomatic showTooltip source and there is no runtime prop to reconfigure it
  // against (slot presence is decided once at mount).

  // language is a convenience prop mapping to the ONE bundled language
  // (@codemirror/lang-javascript). Any other value → [] (plain text, no syntax
  // highlighting); consumers add other languages via :extensions (D-03). This
  // FIXES the prior declared-but-ignored bug where buildState hard-coded
  // javascript() regardless of $props.language.
  function langExt() {
    return local.language === 'javascript' ? javascript() : [];
  }

  // theme resolution (G3): the two built-in strings map to oneDark / [];
  // anything else is treated as a CM Extension (or Extension[]) and passed
  // straight through the themeCompartment. The $watch(theme) reconfigure below
  // covers extension themes live, identical to the string forms.
  function themeExt(): any {
    const t = local.theme;
    if (t === 'dark') return oneDark;
    if (t === 'light' || t === '' || t == null) return [];
    // t is a CM Extension / Extension[] passthrough by this branch (the widened
    // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
    // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
    // accept it; the type-neutral targets strip types entirely.
    return t;
  }

  // placeholder ext only when a non-empty placeholder string is supplied.
  function phExt() {
    return local.placeholder ? placeholderExt(local.placeholder) : [];
  }

  // baseline keymap/gutter set (G1). When `basicSetup` is on, use CM6's
  // `basicSetup` bundle (autocomplete, search, bracket matching, code folding,
  // lint gutter, richer keymaps — it ALREADY includes line numbers + history, so
  // the manual trio would double those up). When off, keep the exact thin
  // baseline the wrapper shipped before G1 (line numbers + history + default/
  // history keymaps) → existing consumers stay byte-stable. Read at buildState
  // time only — no compartment (see the basicSetup prop note).
  function baselineExt() {
    return local.basicSetup ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])];
  }

  // buildState + the panel-slot wiring live INSIDE $onMount so the $portals.panel
  // reference is bound in the mount scope. The per-target emitters scope the
  // concrete portal helper inside the mount lifecycle (React useEffect / Lit
  // firstUpdated / etc.); a top-level `panelExt` that references $portals would
  // land out-of-scope of that helper and fail the bundled-leaf strict typecheck
  // (TS2304 'portals' / TS2742). Keeping the $portals.panel use inside $onMount
  // mirrors FullCalendar's portal pattern (its eventContent callbacks reference
  // $portals only inside $onMount). buildState is only ever called here, so this
  // is a behavior-preserving relocation. Compartments + langExt/themeExt/phExt
  // stay top-level — the $watch reconfigures still reference them.

  // Shared suppress-echo write helper. Both the $watch(value) consumer-driven
  // reflect AND the $expose setValue verb route through this so a programmatic
  // or prop-driven set doesn't ping-pong back through the model path. When the
  // editor itself was the source of the change, the doc already matches `v`, so
  // dispatching another transaction would mint a duplicate undo-history entry
  // for no UI change.
  function writeDoc(v: any) {
    if (!view) return;
    const current = view.state.doc.toString();
    const next = v ?? '';
    if (current === next) return;
    suppressEmit = true;
    try {
      view.dispatch({
        changes: {
          from: 0,
          to: current.length,
          insert: next
        }
      });
    } finally {
      suppressEmit = false;
    }
  }

  // Consumer-driven value writes: reflect into the live editor (echo-guarded).

  // Imperative handle (Phase 21 $expose). The 12 editor verbs a consumer can't
  // drive through props alone — exposed uniformly to all 6 targets. Each guards
  // the pre-mount/destroyed `view = null`. Collision-clear: none of the names
  // collide with the props (value/language/theme/readOnly/height/placeholder/
  // extensions/basicSetup/gutterLines/decorations) and there are no events (D-08).
  //
  // undo/redo/selectAll are the basic editor-command verbs a toolbar needs (history
  // ships with basicSetup / the bundled `history()` extension); the @codemirror/
  // commands functions are reached via the `cmCommands` namespace import so the
  // public verb names don't self-shadow the imports. scrollToPos reveals a document
  // position — it is NOT named `scrollIntoView`/`scrollTo` (both inherited
  // HTMLElement methods → would shadow on the Lit leaf, the embla scrollTo lesson).
  function getView() {
    return view;
  }
  function focus() {
    view?.focus();
  }
  function getValue() {
    return view ? view.state.doc.toString() : '';
  }
  // replaceValue routes through the SAME suppress-echo guard as $watch(value).
  // NOTE: named `replaceValue` (not `setValue`) — a `value` model prop makes
  // React auto-generate a `setValue` state setter, so a `setValue` $expose verb
  // collides on the React target (ROZ524: "already declared" + recursive rewrite).
  // Renamed to preserve the value-setter semantics collision-free across all 6
  // targets. (Deviation from the locked D-06 name `setValue`.)
  function replaceValue(v: any) {
    writeDoc(v);
  }
  function dispatch(tr: any) {
    view?.dispatch(tr);
  }
  function insertText(text: any) {
    if (!view) return;
    const {
      from,
      to
    } = view.state.selection.main;
    view.dispatch({
      changes: {
        from,
        to,
        insert: text
      },
      userEvent: 'input.type'
    });
  }
  function getSelection() {
    return view ? view.state.selection.main : null;
  }
  function setSelection(range: any) {
    if (!view) return;
    const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
    view.dispatch({
      selection: sel
    });
  }
  function undo() {
    if (view) cmCommands.undo(view);
  }
  function redo() {
    if (view) cmCommands.redo(view);
  }
  function selectAll() {
    if (view) cmCommands.selectAll(view);
  }
  // Reveal a document position (jump-to-line, scroll-to-match/error). setSelection
  // moves the caret but does not guarantee scroll; this dispatches the scroll
  // effect. opts default centers the position vertically.
  function scrollToPos(pos: any, opts: any) {
    if (!view) return;
    view.dispatch({
      effects: EditorView.scrollIntoView(pos, opts ?? {
        y: 'center'
      })
    });
  }

  return (
    <>
    <div class={"rozie-codemirror"} style={{ height: local.height + 'px' }} data-rozie-s-34cfda5a="">
      <div class={"cm-mount"} ref={(el) => { hostElRef = el as HTMLElement; }} data-rozie-s-34cfda5a="" />
    </div>










    </>
  );
}
ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, injectGlobalStyles } from '@rozie/runtime-lit';
import { styleMap } from 'lit/directives/style-map.js';
import { EditorState, Compartment, EditorSelection, StateField, RangeSet } from '@codemirror/state';
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
// `gutter` is imported under an alias: the `gutter` SLOT (G5 wave 2) lowers into
// a same-scope local on targets that bind slots as locals (Svelte snippet prop
// `gutter`, etc.), so the bare CM6 `gutter` import would collide ("Identifier
// 'gutter' has already been declared"). Same discipline as `basicSetup as
// basicSetupBundle` below (a prop-vs-import collision). The `decoration` slot has
// no matching import name, so `Decoration` (capitalized, distinct) needs no alias.
import { EditorView, keymap, lineNumbers, showPanel, showTooltip, placeholder as placeholderExt, gutter as gutterExt, GutterMarker, Decoration, WidgetType } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
// Namespace import for the command functions exposed as verbs (undo/redo/
// selectAll). A NAMED `import { undo as undoCmd }` would put the export name
// `undo` in an ImportSpecifier's `imported` slot, and the Lit emitter's
// identifier-rewrite (exposed verb → `this.undo`) mis-rewrites that slot into a
// MemberExpression — a latent emitter limitation. The namespace form keeps the
// command names as MEMBER accesses (`cmCommands.undo`), which the rewrite leaves
// untouched, so the public verbs can be named `undo`/`redo`/`selectAll`.
import * as cmCommands from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
// Imported under an alias: the `basicSetup` PROP (G1) would otherwise collide
// with this binding on targets that lower props into same-scope locals (Svelte
// `let basicSetup`, Solid/React destructured `props.basicSetup`).
import { basicSetup as basicSetupBundle } from 'codemirror';

interface RoziePanelSlotCtx {
  view: unknown;
}

interface RozieTopPanelSlotCtx {
  view: unknown;
}

interface RozieTooltipSlotCtx {
  view: unknown;
  pos: unknown;
}

interface RozieGutterSlotCtx {
  line: unknown;
  view: unknown;
}

interface RozieDecorationSlotCtx {
  from: unknown;
  to: unknown;
  view: unknown;
}

@customElement('rozie-code-mirror')
export default class CodeMirror extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-codemirror[data-rozie-s-34cfda5a] {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 4px;
  overflow: hidden;
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}
.cm-mount[data-rozie-s-34cfda5a] {
  height: 100%;
  width: 100%;
}
.rozie-codemirror .cm-editor {
    height: 100%;
    font-size: 13px;
  }
.rozie-codemirror .cm-scroller {
    height: 100%;
  }
.rozie-codemirror .rozie-cm-panel {
    padding: 2px 8px;
    border-top: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-panel-top {
    padding: 2px 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-tooltip {
    padding: 2px 6px;
    font-size: 11px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 3px;
    white-space: nowrap;
  }
.rozie-codemirror .rozie-cm-gutter {
    min-width: 14px;
  }
.rozie-codemirror .rozie-cm-gutter-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    font-size: 11px;
    line-height: 1;
  }
.rozie-codemirror .rozie-cm-decoration {
    display: inline-flex;
    align-items: center;
    vertical-align: text-bottom;
  }
`;

  /**
   * The two-way document text (`r-model:value`) — the editor's contents as a string. Typing in the editor writes the new text back through the model path (CodeMirror's `updateListener` extension); a consumer write reflects into the live document, echo-guarded so a programmatic set does not ping-pong. As the sole `model: true` prop this **is** the only change channel — there are no events.
   * @example
   * <CodeMirror r-model:value="source" language="javascript" theme="dark" />
   */
  @property({ type: String, attribute: 'value' }) _value_attr: string = '';
  private _valueControllable = createLitControllableProperty<string>({ host: this, eventName: 'value-change', defaultValue: '', initialControlledValue: undefined });
  /**
   * Convenience language. `javascript` loads the bundled `@codemirror/lang-javascript`; any other value falls back to plain text (no syntax highlighting, no throw). Add other languages through `:extensions`. Runtime-updatable via a `langCompartment` reconfigure — switching the prop re-highlights without a remount.
   */
  @property({ type: String, reflect: true }) language: string = 'javascript';
  /**
   * Editor theme. The built-in strings `light` (the editor default — no theme) or `dark` (the bundled `@codemirror/theme-one-dark`), **or** a CodeMirror `Extension` / `Extension[]` passed straight through (G3) — drop in a theme package or an `EditorView.theme({…})`. A non-string theme is composed via the live `themeCompartment` so it reconfigures with no remount, same as the string forms.
   */
  @property({ type: Object }) theme: unknown = 'light';
  /**
   * Make the document read-only. Runtime-updatable via a `readOnlyCompartment` reconfigure (no remount).
   */
  @property({ type: Boolean, reflect: true }) readOnly: boolean = false;
  /**
   * Editor height in pixels, applied to the wrapper's host box.
   */
  @property({ type: Number, reflect: true }) height: number = 240;
  /**
   * Placeholder text shown when the document is empty (the bundled `@codemirror/view` `placeholder` extension). An empty string means no placeholder. Runtime-updatable via a `placeholderCompartment` reconfigure.
   */
  @property({ type: String, reflect: true }) placeholder: string = '';
  /**
   * Consumer-extensible passthrough — an arbitrary `Extension[]` composed **last** so it wins CodeMirror's last-registered-wins facets. The CodeMirror 6 analog of an options bag: line-wrapping, autocomplete, linting, custom key-bindings, additional languages/themes — anything the curated props do not special-case. Runtime-reconfigurable via an `extensionsCompartment` (no remount when the array changes).
   */
  @property({ type: Array }) extensions: any[] = [];
  /**
   * When `true`, swap the thin manual baseline (line numbers + history + default/history keymaps) for CodeMirror 6's batteries-included `basicSetup` bundle — autocomplete, search, bracket matching, code folding, lint gutter, and richer keymaps. The curated props and consumer `:extensions` still compose **after** it, so they continue to win. **Construction-time only:** read once when the editor is built (no compartment), so toggling it at runtime requires a re-mount — set it as a fixed prop, do not flip it live.
   */
  @property({ type: Boolean, reflect: true }) basicSetup: boolean = false;
  /**
   * The 1-based line numbers that each get a custom gutter marker rendered by the `gutter` reactive multi-instance portal slot (one portal handle per visible marker). Out-of-range lines are ignored. Runtime-updatable via a `gutterCompartment` reconfigure — changing the array re-marks the lines with no remount. Only meaningful when the `gutter` slot is filled.
   */
  @property({ type: Array }) gutterLines: any[] = [];
  /**
   * An array of `{ from, to? }` **0-based document offsets** that each get an inline widget rendered by the `decoration` reactive multi-instance portal slot (one portal handle per visible widget). A point widget is placed at `from`; `to` is passed through in scope for the consumer's awareness. Compute an offset from a line via `view.state.doc.line(n).from`. Runtime-updatable via a `decorationCompartment` reconfigure. Only meaningful when the `decoration` slot is filled.
   */
  @property({ type: Array }) decorations: any[] = [];
  @query('[data-rozie-ref="hostEl"]') private _refHostEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();

  @state() private _hasSlotPanel = false;
  @queryAssignedElements({ slot: 'panel', flatten: true }) private _slotPanelElements!: Element[];
  @property({ attribute: false }) panel?: (scope: { view: unknown }) => unknown;
  @state() private _hasSlotTopPanel = false;
  @queryAssignedElements({ slot: 'topPanel', flatten: true }) private _slotTopPanelElements!: Element[];
  @property({ attribute: false }) topPanel?: (scope: { view: unknown }) => unknown;
  @state() private _hasSlotTooltip = false;
  @queryAssignedElements({ slot: 'tooltip', flatten: true }) private _slotTooltipElements!: Element[];
  @property({ attribute: false }) tooltip?: (scope: { view: unknown; pos: unknown }) => unknown;
  @state() private _hasSlotGutter = false;
  @queryAssignedElements({ slot: 'gutter', flatten: true }) private _slotGutterElements!: Element[];
  @property({ attribute: false }) gutter?: (scope: { line: unknown; view: unknown }) => unknown;
  @state() private _hasSlotDecoration = false;
  @queryAssignedElements({ slot: 'decoration', flatten: true }) private _slotDecorationElements!: Element[];
  @property({ attribute: false }) decoration?: (scope: { from: unknown; to: unknown; view: unknown }) => unknown;

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

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

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

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

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

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="decoration"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDecoration = this._slotDecorationElements.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._hasSlotPanel = Array.from(this.children).some((el) => el.getAttribute('slot') === 'panel');
    this._hasSlotTopPanel = Array.from(this.children).some((el) => el.getAttribute('slot') === 'topPanel');
    this._hasSlotTooltip = Array.from(this.children).some((el) => el.getAttribute('slot') === 'tooltip');
    this._hasSlotGutter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'gutter');
    this._hasSlotDecoration = Array.from(this.children).some((el) => el.getAttribute('slot') === 'decoration');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    adoptDocumentStyles(this);

    this._armListeners();

    interface ReactivePortalHandle {
      update(scope: unknown): void;
      dispose(): void;
    }
    const portals = {
      panel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
        const tpl = this.panel;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-panel', '34cfda5a');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
      topPanel: (container: HTMLElement, scope: { view: unknown }): (() => void) => {
        const tpl = this.topPanel;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-topPanel', '34cfda5a');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
      tooltip: (container: HTMLElement, scope: { view: unknown; pos: unknown }): ReactivePortalHandle => {
        const tpl = this.tooltip;
        if (typeof tpl !== 'function') return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-tooltip', '34cfda5a');
        const renderScope = (s: { view: unknown; pos: unknown }): void => {
          render(tpl(s), container);
        };
        renderScope(scope);
        this._portalContainers.add(container);
        return {
          update: (s: { view: unknown; pos: unknown }): void => renderScope(s),
          dispose: (): void => {
            render(nothing, container);
            this._portalContainers.delete(container);
          },
        };
      },
      gutter: (container: HTMLElement, scope: { line: unknown; view: unknown }): ReactivePortalHandle => {
        const tpl = this.gutter;
        if (typeof tpl !== 'function') return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-gutter', '34cfda5a');
        const renderScope = (s: { line: unknown; view: unknown }): void => {
          render(tpl(s), container);
        };
        renderScope(scope);
        this._portalContainers.add(container);
        return {
          update: (s: { line: unknown; view: unknown }): void => renderScope(s),
          dispose: (): void => {
            render(nothing, container);
            this._portalContainers.delete(container);
          },
        };
      },
      decoration: (container: HTMLElement, scope: { from: unknown; to: unknown; view: unknown }): ReactivePortalHandle => {
        const tpl = this.decoration;
        if (typeof tpl !== 'function') return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-decoration', '34cfda5a');
        const renderScope = (s: { from: unknown; to: unknown; view: unknown }): void => {
          render(tpl(s), container);
        };
        renderScope(scope);
        this._portalContainers.add(container);
        return {
          update: (s: { from: unknown; to: unknown; view: unknown }): void => renderScope(s),
          dispose: (): void => {
            render(nothing, container);
            this._portalContainers.delete(container);
          },
        };
      },
    };

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

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.value)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => this.writeDoc(v))(__watchVal); }); }));

    // One `panel` portal slot — mounted through CM6's `showPanel` facet. The
    // Panel's `dom` is the portal host node; $portals.panel(dom, scope) mounts the
    // consumer's framework-native fragment on Panel.mount() and the returned
    // dispose runs in Panel.destroy(). Empty extension ([]) when the consumer
    // doesn't fill the slot.
    // NOTE: the Panel's mount/destroy are ARROW-FUNCTION properties (not object
    // `mount() {}` methods) and the panel host element + view are captured in
    // plain `const`s. The object-method form gives each method its own `this`
    // scope, and the Lit emitter's component-field rewrite walks INTO that method
    // body and rewrites a closure-captured `view` reference to `this.view`
    // (TS2339 "Property 'view' does not exist on type 'Panel'"). Arrow-function
    // properties share the enclosing lexical scope, so the captured `panelView`
    // const resolves correctly on every target. CM6 calls `panel.mount()` /
    // `panel.destroy()` either way.
    const panelExt = () => {
      if (!(this.panel !== undefined)) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: false,
          mount: () => {
            dispose = portals.panel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    // topPanel — the TOP-docked mount-once sibling of `panel` (G5 wave 1). Same
    // `showPanel` facet, same arrow-function-property mount/destroy form (NOT
    // object-method `mount() {}` — the Lit field-rewrite caveat documented on
    // panelExt above applies identically), differing ONLY in `top: true` and the
    // `.rozie-cm-panel-top` host class. Empty ([]) when the slot is unfilled.
    const topPanelExt = () => {
      if (!(this.topPanel !== undefined)) return [];
      return showPanel.of((panelView: any) => {
        const dom = document.createElement('div');
        dom.className = 'rozie-cm-panel-top';
        const scope = {
          view: panelView
        };
        let dispose: any = null;
        return {
          dom,
          top: true,
          mount: () => {
            dispose = portals.topPanel(dom, scope);
          },
          destroy: () => {
            dispose?.();
            dispose = null;
          }
        };
      });
    };

    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    // tooltip — CodeMirror's FIRST REACTIVE portal slot (G5 wave 1). A
    // cursor-anchored tooltip provided through the `showTooltip` facet via a
    // StateField that yields ONE Tooltip at the main selection head whenever the
    // `tooltip` slot is filled.
    //
    // UPDATE-IN-PLACE reconciliation (verified against the installed
    // @codemirror/view@6.43 tooltip source, TooltipViewManager.update): CM reuses
    // an existing TooltipView — calling TooltipView.update(viewUpdate) instead of
    // destroy+create — when the new Tooltip's `create` is REFERENCE-EQUAL to the
    // old one's (`other.create == tip.create`); and when the whole showTooltip
    // facet INPUT is unchanged it skips matching entirely and calls update() on
    // every live view. We satisfy BOTH by holding ONE module-stable Tooltip object
    // (`stableTooltip`, stable `create`) and returning that SAME object from the
    // field's `update` while the head only moved. So the consumer fragment mounts
    // ONCE (TooltipView.mount → $portals.tooltip → reactive {update,dispose}) and
    // every caret move flows through TooltipView.update → handle.update(scope) —
    // re-rendering the fragment IN PLACE, never remounting it.
    const tooltipField = () => {
      if (!(this.tooltip !== undefined)) return [];
      // The reactive portal handle for the SINGLE live tooltip view. Hoisted to
      // the field's closure so create()/update()/destroy() share it across the
      // tooltip's lifetime.
      let handle: any = null;
      // Stable Tooltip object — its `create` reference never changes, so CM
      // reuses the TooltipView across caret moves (update-in-place, no remount).
      // NOTE: `create` is an ARROW-FUNCTION property and its param is named
      // `tipView` (NOT `view`) — both for the SAME Lit reason documented on
      // panelExt: an object-method `create(view) {}` would get its own `this`,
      // and the Lit emitter's component-field rewrite walks into the body and
      // rewrites a `view`-named token (matching the top-level `let view`) to
      // `this.view`. An arrow property shares the enclosing scope, and the
      // non-colliding param name keeps the caret-view reference correct on every
      // target. CM calls `tooltip.create(view)` either way.
      const stableTooltip = {
        pos: 0,
        above: true,
        create: (tipView: any) => {
          const dom = document.createElement('div');
          dom.className = 'rozie-cm-tooltip';
          return {
            dom,
            mount: () => {
              handle = portals.tooltip(dom, {
                view: tipView,
                pos: tipView.state.selection.main.head
              });
            },
            // Reactive in-place update — fired by CM on every ViewUpdate while the
            // tooltip view is reused. Re-renders the consumer fragment with the
            // fresh caret position; the fragment is NOT remounted (REQ — verified
            // empirically via the demo's mount/update counters).
            update: (u: any) => {
              handle?.update({
                view: u.view,
                pos: u.state.selection.main.head
              });
            },
            destroy: () => {
              handle?.dispose();
              handle = null;
            }
          };
        }
      };
      // NOTE: the StateField.update callback's first param is named `cur` (NOT the
      // idiomatic `value`): a `value` model prop makes the React emitter rewrite a
      // local `value` binding into the prop-state ref (`_valueRef.current`) — it
      // walks into this callback and corrupts the field's accumulator
      // (TS2339 "Property 'pos' does not exist on type 'string'"). Same collision
      // class as the setValue→replaceValue $expose rename (ROZ524). `cur` is
      // collision-free across all 6 targets.
      return StateField.define({
        create: (state: any) => ({
          ...stableTooltip,
          pos: state.selection.main.head
        }),
        update: (cur: any, tr: any) => {
          // Keep the SAME stable `create`; only the head moves. Reuse the existing
          // object when the head is unchanged so the facet input is identity-stable.
          const head = tr.state.selection.main.head;
          if (cur && cur.pos === head) return cur;
          return {
            ...stableTooltip,
            pos: head
          };
        },
        provide: (f: any) => showTooltip.from(f)
      });
    };

    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    // gutter — a custom-gutter REACTIVE MULTI-INSTANCE portal slot (G5 wave 2).
    // Each line in `gutterLines` gets a `RozieGutterMarker` whose `toDOM` mounts
    // the consumer fragment via $portals.gutter(dom, scope) — ONE live portal
    // handle PER VISIBLE marker (CM calls toDOM when the line scrolls into view and
    // destroy() when it scrolls out; the reactive handle disposes cleanly). This is
    // the TipTap nodeView multi-instance template: the GutterMarker class captures
    // $portals.gutter and is therefore defined inside this $onMount-invoked factory.
    //
    // The GutterMarker subclass is declared inline (GutterMarker REQUIRES
    // subclassing), but its per-marker state (`line`, the live portal handle) lives
    // in CLOSURE — `makeGutterMarker(line)` captures them — NOT in `this` fields.
    // This is deliberate for the strict-tsc bundled leaves (react/solid/lit): ES
    // class fields assigned only in the constructor (`this.line = …`) without a
    // declaration trip TS2339 under those leaves' strict tsc, and the emitter passes
    // the class through verbatim (a class-field type aid is an emitter concern, OUT
    // OF SCOPE). Closure capture has zero `this`-field surface, so it typechecks
    // cleanly across all six. The overriding CM methods (toDOM/destroy) cannot carry
    // the TS-only `override` keyword — the `<script>` is plain JS (no `lang="ts"`),
    // so `override` is unparseable — so the three bundled leaves relax
    // `noImplicitOverride` in their tsconfig (the Lit leaf already did; react/solid
    // now match). The `view` param is named `mView` — the Lit field-rewrite walks
    // into a method body and rewrites a bare `view` token (matching the top-level
    // `let view`) to `this.view`; `mView` is collision-free. (The panelExt lesson.)
    const makeGutterExt = (gv: any) => {
      if (!(this.gutter !== undefined)) return [];
      const makeGutterMarker = (line: any) => {
        let handle: any = null;
        return new class extends GutterMarker {
          toDOM(mView: any) {
            const dom = document.createElement('div');
            dom.className = 'rozie-cm-gutter-marker';
            handle = gv(dom, {
              line,
              view: mView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
        }();
      };
      // Recompute the marker RangeSet from `gutterLines` against the live doc —
      // one marker at the START of each in-range line. RangeSet.of REQUIRES the
      // ranges sorted by `from`, so sort the resolved positions.
      const buildMarkers = (mView: any): any => {
        const doc = mView.state.doc;
        const ranges = [];
        for (const n of this.gutterLines as any) {
          if (typeof n !== 'number' || n < 1 || n > doc.lines) continue;
          ranges.push(makeGutterMarker(n).range(doc.line(n).from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return RangeSet.of(ranges);
      };
      return gutterExt({
        class: 'rozie-cm-gutter',
        markers: (mView: any) => buildMarkers(mView)
      });
    };

    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    // decoration — an inline-widget REACTIVE MULTI-INSTANCE portal slot (G5 wave
    // 2). Each `{ from, to? }` in `decorations` gets a `RozieWidget` whose `toDOM`
    // mounts the consumer fragment via $portals.decoration(dom, scope) — ONE live
    // portal handle PER VISIBLE widget. The decoration set is provided through a
    // compartment-wrapped facet so the `decorations` prop reconfigures it live.
    // The WidgetType class captures $portals.decoration, so it is defined inside
    // this $onMount-invoked factory (the bundled-leaf typecheck discipline).
    const makeDecorationExt = (dv: any) => {
      // Unfilled slot → EMPTY EXTENSION (`[]`), NOT `Decoration.none`. The latter
      // is a DecorationSet (a RangeSet), which is NOT a valid Extension; placing it
      // in the extensions array (via `decorationCompartment.of(...)`) makes
      // EditorState.create throw at runtime — the editor never mounts. Only the
      // browser surfaces this (CM's facet types are loose, so build/typecheck pass).
      if (!(this.decoration !== undefined)) return [];
      // The WidgetType subclass is declared inline (WidgetType REQUIRES subclassing)
      // but its per-widget state (`from`/`to`, the live portal handle) lives in
      // CLOSURE — `makeWidget(from, to)` captures them — NOT in `this` fields, for
      // the same strict-tsc-bundled-leaf reason as the gutter marker (undeclared
      // `this` fields trip TS2339; the overriding methods can't carry the TS-only
      // `override` keyword from plain-JS `<script>`, so the bundled leaves relax
      // `noImplicitOverride`). No `eq` override is needed: the decoration set is
      // rebuilt from the prop on every reconfigure, so default reference-`eq`
      // (always "different") correctly remounts each widget instead of reusing stale
      // DOM. The `view` param is `dView` (the Lit field-rewrite lesson).
      const makeWidget = (from: any, to: any) => {
        let handle: any = null;
        return new class extends WidgetType {
          toDOM(dView: any) {
            const dom = document.createElement('span');
            dom.className = 'rozie-cm-decoration';
            handle = dv(dom, {
              from,
              to,
              view: dView
            });
            return dom;
          }
          destroy() {
            handle?.dispose();
            handle = null;
          }
          // Inline widgets must not be considered editable content.
          ignoreEvent() {
            return false;
          }
        }();
      };
      // Build the DecorationSet from `decorations` against the live doc. Each entry
      // is a point widget at `from` (side: 1 — after the position); out-of-range
      // offsets are clamped to the doc length and skipped if `from` is invalid.
      // Decoration.set REQUIRES the ranges sorted by `from`.
      const buildSet = (state: any) => {
        const len = state.doc.length;
        const ranges = [];
        for (const d of this.decorations as any) {
          if (!d || typeof d.from !== 'number') continue;
          const from = Math.max(0, Math.min(d.from, len));
          const to = typeof d.to === 'number' ? Math.max(0, Math.min(d.to, len)) : from;
          ranges.push(Decoration.widget({
            widget: makeWidget(from, to),
            side: 1
          }).range(from));
        }
        ranges.sort((a: any, b: any) => a.from - b.from);
        return Decoration.set(ranges);
      };
      // A StateField yields the DecorationSet and provides it to EditorView.
      // decorations. The set is rebuilt on every prop-driven reconfigure (the
      // $watch dispatches decorationCompartment.reconfigure(makeDecorationExt(…))),
      // and tracked across local doc edits via mapping so widget positions follow.
      return StateField.define({
        create: (state: any) => buildSet(state),
        update: (deco: any, tr: any) => tr.docChanged ? deco.map(tr.changes) : deco,
        provide: (f: any) => EditorView.decorations.from(f)
      });
    };

    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    // Bridge the mount-built factories to the top-level $watch reconfigures. Each
    // closes over the captured $portals helper so a prop change can rebuild the
    // extension without re-referencing $portals at top level.
    this.rebuildGutterExt = () => makeGutterExt(portals.gutter);
    this.rebuildDecorationExt = () => makeDecorationExt(portals.decoration);
    const buildState = (doc: any) => EditorState.create({
      doc,
      extensions: [...this.baselineExt(), this.langCompartment.of(this.langExt()), this.themeCompartment.of(this.themeExt()), this.readOnlyCompartment.of(EditorState.readOnly.of(this.readOnly)), this.placeholderCompartment.of(this.phExt()), this.panelCompartment.of(panelExt()), this.topPanelCompartment.of(topPanelExt()),
      // gutter / decoration — the REACTIVE MULTI-INSTANCE portal slots (G5 wave
      // 2). Each lives in a compartment so its driving prop (gutterLines /
      // decorations) reconfigures live; the factory captures the per-target
      // $portals helper (gutter / decoration) here in the mount scope.
      this.gutterCompartment.of(this.rebuildGutterExt()), this.decorationCompartment.of(this.rebuildDecorationExt()),
      // tooltipField() returns a StateField extension (or [] when the slot is
      // unfilled); no compartment — it is a one-shot mount-time decision.
      tooltipField(), EditorView.updateListener.of((update: any) => {
        if (!update.docChanged) return;
        if (this.suppressEmit) return;
        // Push the new doc out through the model:true emit path. Consumers
        // bound via `r-model:value="$data.x"` receive the change.
        this._valueControllable.write(update.state.doc.toString());
      }),
      // Consumer extensions LAST so they win CM6's last-registered-wins facets.
      this.extensionsCompartment.of(this.extensions)]
    });
    this.view = new EditorView({
      state: buildState(this.value),
      parent: this._refHostEl
    });
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (this.__rozieFirstUpdateDone && (changedProperties.has('language'))) { const __watchVal = (() => this.language)(); (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.langCompartment.reconfigure(this.langExt())
      });
    })(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('theme'))) { const __watchVal = (() => this.theme)(); (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.themeCompartment.reconfigure(this.themeExt())
      });
    })(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('readOnly'))) { const __watchVal = (() => this.readOnly)(); ((v: any) => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(v))
      });
    })(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('placeholder'))) { const __watchVal = (() => this.placeholder)(); (() => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.placeholderCompartment.reconfigure(this.phExt())
      });
    })(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('extensions'))) { const __watchVal = (() => this.extensions)(); ((v: any) => {
      if (!this.view) return;
      this.view.dispatch({
        effects: this.extensionsCompartment.reconfigure(v)
      });
    })(__watchVal); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('gutterLines'))) { const __watchVal = (() => this.gutterLines)(); (() => {
      if (!this.view || !this.rebuildGutterExt) return;
      this.view.dispatch({
        effects: this.gutterCompartment.reconfigure(this.rebuildGutterExt())
      });
    })(); }
    if (this.__rozieFirstUpdateDone && (changedProperties.has('decorations'))) { const __watchVal = (() => this.decorations)(); (() => {
      if (!this.view || !this.rebuildDecorationExt) return;
      this.view.dispatch({
        effects: this.decorationCompartment.reconfigure(this.rebuildDecorationExt())
      });
    })(); }
    this.__rozieFirstUpdateDone = true;
  }

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

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

  render() {
    return html`
<div class="rozie-codemirror" style=${styleMap({ height: this.height + 'px' })} data-rozie-s-34cfda5a>
  <div class="cm-mount" data-rozie-ref="hostEl" data-rozie-s-34cfda5a></div>
</div>

<slot name="panel"></slot>

<slot name="topPanel"></slot>

<slot name="tooltip"></slot>

<slot name="gutter"></slot>

<slot name="decoration"></slot>
`;
  }

  view: any = null;

  suppressEmit = false;

  langCompartment = new Compartment();

  themeCompartment = new Compartment();

  readOnlyCompartment = new Compartment();

  placeholderCompartment = new Compartment();

  extensionsCompartment = new Compartment();

  panelCompartment = new Compartment();

  topPanelCompartment = new Compartment();

  gutterCompartment = new Compartment();

  decorationCompartment = new Compartment();

  rebuildGutterExt: any = null;

  rebuildDecorationExt: any = null;

  langExt = (): any => this.language === 'javascript' ? javascript() : [];

  themeExt = (): any => {
  const t = this.theme;
  if (t === 'dark') return oneDark;
  if (t === 'light' || t === '' || t == null) return [];
  // t is a CM Extension / Extension[] passthrough by this branch (the widened
  // `theme` prop accepts a string OR an Extension). The strict-tsc leaves get a
  // codegen return-type aid (`themeExt(): any`) so `Compartment.of`/`reconfigure`
  // accept it; the type-neutral targets strip types entirely.
  return t;
};

  phExt = (): any => this.placeholder ? placeholderExt(this.placeholder) : [];

  baselineExt = () => this.basicSetup ? [basicSetupBundle] : [lineNumbers(), history(), keymap.of([...defaultKeymap, ...historyKeymap])];

  writeDoc = (v: any) => {
  if (!this.view) return;
  const current = this.view.state.doc.toString();
  const next = v ?? '';
  if (current === next) return;
  this.suppressEmit = true;
  try {
    this.view.dispatch({
      changes: {
        from: 0,
        to: current.length,
        insert: next
      }
    });
  } finally {
    this.suppressEmit = false;
  }
};

  getView() {
    return this.view;
  }

  focus() {
    this.view?.focus();
  }

  getValue() {
    return this.view ? this.view.state.doc.toString() : '';
  }

  replaceValue(v: any) {
    this.writeDoc(v);
  }

  dispatch(tr: any) {
    this.view?.dispatch(tr);
  }

  insertText(text: any) {
    if (!this.view) return;
    const {
      from,
      to
    } = this.view.state.selection.main;
    this.view.dispatch({
      changes: {
        from,
        to,
        insert: text
      },
      userEvent: 'input.type'
    });
  }

  getSelection() {
    return this.view ? this.view.state.selection.main : null;
  }

  setSelection(range: any) {
    if (!this.view) return;
    const sel = typeof range === 'number' ? EditorSelection.single(range) : EditorSelection.single(range.anchor, range.head);
    this.view.dispatch({
      selection: sel
    });
  }

  undo() {
    if (this.view) cmCommands.undo(this.view);
  }

  redo() {
    if (this.view) cmCommands.redo(this.view);
  }

  selectAll() {
    if (this.view) cmCommands.selectAll(this.view);
  }

  scrollToPos(pos: any, opts: any) {
    if (!this.view) return;
    this.view.dispatch({
      effects: EditorView.scrollIntoView(pos, opts ?? {
        y: 'center'
      })
    });
  }

  get value(): string { return this._valueControllable.read(); }
  set value(v: string) { this._valueControllable.notifyPropertyWrite(v); }
}

injectGlobalStyles('rozie-code-mirror-global', `
.rozie-codemirror .cm-editor {
    height: 100%;
    font-size: 13px;
  }
.rozie-codemirror .cm-scroller {
    height: 100%;
  }
.rozie-codemirror .rozie-cm-panel {
    padding: 2px 8px;
    border-top: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-panel-top {
    padding: 2px 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.12);
    font-size: 12px;
  }
.rozie-codemirror .rozie-cm-tooltip {
    padding: 2px 6px;
    font-size: 11px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 3px;
    white-space: nowrap;
  }
.rozie-codemirror .rozie-cm-gutter {
    min-width: 14px;
  }
.rozie-codemirror .rozie-cm-gutter-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    font-size: 11px;
    line-height: 1;
  }
.rozie-codemirror .rozie-cm-decoration {
    display: inline-flex;
    align-items: center;
    vertical-align: text-bottom;
  }
`);

Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same two-way value, same eight-verb imperative handle, same five portal slots, all from the one source above.

See also

  • CodeMirror — showcase & API — install, quick starts for all six frameworks, the :extensions passthrough, the language presets, and the full prop/handle/slot reference.
  • CodeMirror libraries comparison — how @rozie-ui/codemirror stacks up against the per-framework wrappers (including the Angular binding still on CodeMirror 5).

Pre-v1.0 — internal monorepo.