Skip to content

TipTap — live demo

This is the real @rozie-ui/tiptap-vue package running on this page (VitePress is itself a Vue app). Type in the editor, select text and drive the formatting buttons, or hit Clear — then watch the live HTML readout and word count update. The same TipTap component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.

The document is two-way bound with v-model:html — the readout above updates live as you type, and the buttons drive the imperative handle (toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, focusEditor, clearContent). The component bundles its own toolbar (Bold / Italic / H1 / H2 / Bullet list, with live active-state highlighting); the buttons here are a second, external toolbar driving the same $expose handle. See the full API for the complete prop/event/handle surface — including the toolbar / bubbleMenu / floatingMenu portal slots and the reactive nodeView slot.

What ships for each framework

You author the component once as a .rozie file:

html
<!--
  TipTap.rozie — data-bound port of TipTap (ProseMirror-based rich-text editor).

  TipTap's value isn't the editor logic — that's ProseMirror, framework-
  agnostic. The value-add of the official wrappers (@tiptap/react,
  @tiptap/vue-3, svelte-tiptap, ngx-tiptap, solid-tiptap …) is just gluing
  onUpdate to component state, forwarding extensions, and bridging node
  views. Six maintenance burdens, ONE Rozie source covers six frameworks —
  and crucially gives Lit (no wrapper exists) and Solid (thin, no node
  views) React/Vue-grade ergonomics from the same definition.

  What the official wrappers DON'T give you that this does:
    - True two-way content binding. Neither @tiptap/react nor @tiptap/vue-3
      ships a controlled `value`/v-model contract — every consumer hand-rolls
      the content/onUpdate/setContent sync loop. Here:  <TipTap r-model:html="…" />
    - A batteries-included toolbar with live active-state, OR bring-your-own
      via the `toolbar` portal slot (receives the live editor).
    - Selection-anchored `bubbleMenu` / `floatingMenu` portal slots over the
      Floating-UI menu extensions (receive the live editor).
    - A uniform imperative command handle across all six targets ($expose).

  Surface (Phase 32, feature-rich expansion from the original 4 props;
  character/word count added quick 260720-tzw; link editor added quick
  260721-liz; setLink/unsetLink added quick 260809-6zp):
    - props (14): html[model] / editable / placeholder / autofocus /
                  editorClass / ariaLabel / editorProps / extensions /
                  starterKit / nodeSpecs / uploadImage / maxLength /
                  enforceMaxLength / bubbleMenuShouldShow
    - events (4): update / selectionUpdate / focus / blur
    - $expose (25): getEditor, focusEditor, blurEditor, getHTML, getJSON,
                  getText, setContent, clearContent, toggleBold, toggleItalic,
                  toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList,
                  undo, redo, chain, isActive, can, isEmpty, getCharacterCount,
                  getWordCount, openLinkEditor, setLink, unsetLink
    - slots (6):  3 mount-once portal slots — `toolbar` (consumer toolbar bound
                  to the editor), `bubbleMenu` + `floatingMenu` (selection-anchored
                  menus over @tiptap/extension-bubble-menu / -floating-menu, each
                  handed the live editor) — plus 2 REACTIVE portal slots,
                  `linkEditor` (consumer override for the built-in link-editing
                  surface; scope includes setLink/unsetLink/close) and `nodeView`
                  (consumer fragment rendered as a custom ProseMirror node, driven
                  by the `nodeSpecs` prop — a general custom-node registration
                  facility, not a hardcoded pair) — plus `count`, a plain reactive
                  scoped slot for the character/word counter

  The `editorProps` (ProseMirror) and `extensions` (extra TipTap extensions
  merged onto StarterKit) props are the consumer-extensibility passthroughs —
  the analog of CodeMirror's `:extensions` and Chart.js's `:plugins`. The
  `starterKit` prop is a StarterKit config passthrough (ask A) with
  collision-aware auto-disable so a same-named consumer `extensions` entry
  genuinely wins over StarterKit's internal copy.

  Node-view portal slots (Phase 33) render a framework component as a custom
  ProseMirror node — the marquee TipTap differentiator. The `nodeView` slot is
  the FIRST shipped REACTIVE portal slot: it re-renders the consumer fragment in
  place on every transaction. The primitive is GENERAL (ask B, Phase
  260719-d9e): the consumer registers any number of custom nodes via the
  `nodeSpecs` prop (name/tag/group/inline/atom/content/attrs) and a SINGLE
  nodeView fragment that dispatches on `scope.node.type.name`. The two node
  views that originally proved the primitive — a non-editable @mention atom
  chip (Spike 009 / REQ-26) and an editable contentDOM callout whose
  [data-rozie-hole] placeholder is grafted by the per-target bridge (Spike 008
  / REQ-23) — now ship as `nodeSpecs` recipes in
  `examples/demos/TipTapNodeView*.rozie` rather than being built into the
  component. See docs/guide/tiptap-comparison.md.
-->

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

<props>
{
  html: {
    type: String,
    default: '<p>Start writing…</p>',
    model: true,
    docs: {
      description:
        "The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.",
      example: '<TipTap r-model:html="content" placeholder="Start writing…" />',
    },
  },
  editable: {
    type: Boolean,
    default: true,
    docs: {
      description:
        "Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.",
    },
  },
  placeholder: {
    type: String,
    default: '',
    docs: {
      description:
        'Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.',
    },
  },
  autofocus: {
    type: Boolean,
    default: false,
    docs: {
      description:
        "Whether to place the caret in the document on mount (TipTap's `autofocus` option).",
    },
  },
  editorClass: {
    type: String,
    default: '',
    docs: {
      description:
        'A CSS class applied to the contenteditable element (`editorProps.attributes.class`).',
    },
  },
  ariaLabel: {
    type: String,
    default: 'Rich text editor',
    docs: {
      description:
        'The accessible name (`aria-label`) applied to the contenteditable element.',
    },
  },
  editorProps: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        'ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper\'s attribute defaults.',
    },
  },
  extensions: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit\'s without a "Duplicate extension names" warning.',
    },
  },
  starterKit: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        "StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.",
    },
  },
  nodeSpecs: {
    type: Array,
    default: () => [],
    docs: {
      description:
        "Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.",
      example:
        "<TipTap :node-specs=\"[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]\"><template #nodeView=\"{ node }\">…</template></TipTap>",
    },
  },
  uploadImage: {
    type: Function,
    default: null,
    docs: {
      description:
        "An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.",
      example: '<TipTap :upload-image="uploadFn" />',
    },
  },
  maxLength: {
    type: Number,
    default: null,
    docs: {
      description:
        'A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.',
      example: '<TipTap :max-length="500" />',
    },
  },
  enforceMaxLength: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.',
    },
  },
  bubbleMenuShouldShow: {
    type: Function,
    default: null,
    docs: {
      description:
        "A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.",
      example:
        '<TipTap :bubble-menu-should-show="({ editor }) => editor.isActive(\'table\')"><template #bubbleMenu="{ editor }">…</template></TipTap>',
    },
  },
}
</props>

<data>
{
  active: {
    bold:   false,
    italic: false,
    h1:     false,
    h2:     false,
    bulletList: false,
    underline: false,
    orderedList: false,
    link:   false,
  },
  count: {
    characters: 0,
    words: 0,
  },
  // Named `linkState` (not `link`) — a `$data.link` key would make React
  // auto-generate a `setLink` state setter that collides with the new
  // `setLink` $expose verb (ROZ524, the $data-key==$expose-verb collision
  // class; the setContent/setHtml precedent, this time on a nested $data key
  // rather than the model prop).
  linkState: {
    href: '',
    attrs: {},
  },
}
</data>

<script>
import { Editor, Node } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Placeholder } from '@tiptap/extensions'
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image'
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count'

// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).
let editor = null

// The raw HTML string the editor currently reflects. Compared against in the
// $props.html reconciler so the watcher's mount-time fire is a no-op: the
// editor is created with `content: $props.html`, so right after mount the bound
// model already matches and setContent must NOT re-run (re-running it replaces
// the whole ProseMirror document and resets the selection — the official
// @tiptap/* wrappers guard the same way against the *raw* value, never against
// the normalized `editor.getHTML()`). This is the CodeMirror suppress-echo
// guard in HTML-string form (flatpickr lineage).
let lastHtml = null

// The `toolbar` portal slot's dispose handle. COMPONENT-scope (top-level let),
// NOT a $onMount-local — the Solid emitter hoists the $onMount-returned cleanup
// into a sibling onCleanup() OUTSIDE the mount-body IIFE, so a mount-local would
// lose scope there (the Chart.js tooltipEl/tooltipDispose hoist lesson).
let toolbarDispose = null

// The `bubbleMenu` / `floatingMenu` portal-slot dispose handles + the imperatively
// created menu host elements. COMPONENT-scope for the same hoist reason as
// toolbarDispose — and the host els must be reachable from BOTH the pre-`new
// Editor` extension build (the menu extension needs its `element` at construction)
// AND the post-construction portal mount, so they live here too (not $onMount
// locals). Each stays null when its slot is unfilled (zero overhead, no $portals
// reference fired — the nodeView discipline).
let bubbleMenuEl = null
let bubbleMenuDispose = null
let floatingMenuEl = null
let floatingMenuDispose = null

// ── Link editor (#2) surface. Its OWN dedicated bubble-menu instance (distinct
// `pluginKey: 'rozieLinkEditor'`) with a link-aware trigger, orthogonal to the
// general `bubbleMenu` slot. `linkEditorEl` is the imperatively-created host handed
// to that BubbleMenu extension (the bubbleMenuEl discipline — engine owns
// positioning). COMPONENT-scope for the same hoist reason as the menu els.
//   - When the consumer fills the `#linkEditor` slot → `linkEditorHandle` is the
//     REACTIVE portal handle ({ update, dispose }); refreshLink() re-renders it in
//     place (Spike 016 proved a reactive portal survives the bubble-menu
//     extension's element.remove()/appendChild detach-reattach cycles).
//   - Otherwise → the component builds its OWN default form imperatively into
//     `linkEditorEl` (`linkInputEl` = its URL <input>); refreshLink() imperatively
//     refreshes the input value. Pure-script ⇒ byte-identical across all 6 targets,
//     no framework-reconciliation risk, and no portal default-content (the emitter
//     renders none for an unfilled portal slot).
// `openFlag` = the toolbar Link button's create-mode trigger (set true on click,
// cleared on Apply/Remove/Cancel/blur); the link-aware shouldShow shows the editor
// when `editor.isActive('link')` (edit mode) OR `openFlag` (create mode).
let linkEditorEl = null
let linkEditorHandle = null
let linkInputEl = null
let openFlag = false
// Last link state refreshLink() reflected, as a compare key — lets refreshLink
// early-return when the link mark is unchanged (a keystroke fires BOTH onUpdate
// and onSelectionUpdate, so refreshLink would otherwise run — and re-render the
// #linkEditor fragment — twice per keystroke).
let lastLinkKey = null

// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
  if (!editor) return
  $data.active = {
    bold:       editor.isActive('bold'),
    italic:     editor.isActive('italic'),
    h1:         editor.isActive('heading', { level: 1 }),
    h2:         editor.isActive('heading', { level: 2 }),
    bulletList: editor.isActive('bulletList'),
    underline:  editor.isActive('underline'),
    orderedList: editor.isActive('orderedList'),
    link:       editor.isActive('link'),
  }
}

// ── Link editor (#2) command helpers + reactive refresh. TOP-LEVEL const arrows
// (siblings of refreshActive/refreshCount) so every `editor` read sits at the same
// shallow, proven-safe depth — never nested inside an object-literal method (the
// redirectNestedThis gap [[project_emitter_redirect_nested_this_gap]]). The link
// scope's setLink/unsetLink/close are these top-level fns, referenced by identity
// from buildLinkScope so the consumer fragment (and the built-in form) call the
// SAME verbs. `extendMarkRange('link')` widens the selection to the whole link so
// an edit/removal applies to the entire mark, not just the caret word.
//
// DECLARATION ORDER IS LOAD-BEARING (topological, leaves first): apply/remove/close
// → buildLinkScope → refreshLink → openLinkEditor. The React/Solid/Lit emitters lift
// reactive closures into useCallback/memo with eager dependency ARRAYS, so a forward
// reference to a later-declared reactive const is a hard TS2448 (use-before-decl) —
// unlike a deferred function BODY, which is fine. apply/removeLink therefore do NOT
// call refreshLink (which would make them depend on it and re-introduce a cycle):
// the setLink/unsetLink chain dispatches a transaction that fires onSelectionUpdate +
// onUpdate, both of which already call refreshLink. Only openLinkEditor (safely last)
// calls it, for immediate prefill on the create affordance.
const applyLink = (attrs) => {
  // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
  // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
  if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return
  editor?.chain().focus().extendMarkRange('link').setLink(attrs).run()
  openFlag = false
}
const removeLink = () => {
  editor?.chain().focus().extendMarkRange('link').unsetLink().run()
  openFlag = false
}
// Force the link-editor BubbleMenu to open/close right now, matching openFlag
// (bug fix — quick 260809-6zp). `editor?.commands.focus()` alone is NOT
// sufficient: TipTap's `focus` command early-returns with NO dispatch whenever
// `view.hasFocus() && position === null` (the whole document is already
// focused — the COMMON case once the create/close affordance is invoked from
// a `@mousedown.prevent`-guarded control, which deliberately never blurs the
// editor). And even a dispatched but otherwise-INERT transaction (no doc/
// selection change) is not enough either: @tiptap/extension-bubble-menu's own
// `update()` short-circuits with `isSame = !selectionChanged && !docChanged`
// BEFORE it ever re-runs `shouldShow` — so a no-op dispatch is silently
// swallowed by the extension's OWN guard, not just TipTap's `focus` command.
// The extension's `transactionHandler` (its own doc comment: "This allows
// external code to trigger ... via `editor.view.dispatch(editor.state.tr
// .setMeta(pluginKey, 'updatePosition'))`") is the official escape hatch: a
// transaction tagged with THIS surface's own `pluginKey` ('rozieLinkEditor')
// calls `show()`/`hide()` directly, bypassing both guards. This bit both
// `openLinkEditor` (create-mode toolbar button) and `closeLink` (built-in
// Cancel AND any consumer `close()`), on every target, whenever the editor
// was already focused. `editor` is the raw TipTap `Editor` instance on all 6.
const forceMenuRecheck = () => {
  if (!editor) return
  const visible = editor.isEditable && (editor.isActive('link') || openFlag)
  editor.view.dispatch(editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'))
}
const closeLink = () => {
  openFlag = false
  // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
  // current link href. The surface itself is link-anchored (like Google Docs) — it
  // stays while the caret is on a link and hides once openFlag is clear and the
  // caret is off any link (or the doc is not editable).
  if (linkInputEl) linkInputEl.value = $data.linkState.href
  editor?.commands.focus()
  forceMenuRecheck()
}
// The reactive `#linkEditor` slot scope — keys EXACTLY { editor, href, attrs,
// setLink, unsetLink, close } (spec §5.3). `attrs` is the raw link mark attrs
// object so a consumer can read custom attrs (e.g. data-course-link); setLink
// forwards whatever attrs object it is handed VERBATIM (REQ-42 — persistence of a
// custom attr is the consumer's Link.extend concern, not this wrapper's).
//
// Takes `href`/`attrs` as PARAMETERS rather than reading `$data.linkState` —
// every caller has just computed (or is about to compute) these values
// directly from the live editor, and reading them back off `$data`
// immediately after a same-tick write hits the React setState-is-async
// stale-read trap (the D-04 prefill fix's own class of bug, here on the
// ONGOING reactive-refresh path rather than the one-time mount path). Passing
// them straight through keeps every target reading the value that was ACTUALLY
// just computed, not a framework-buffered echo of it.
const buildLinkScope = (href, attrs) => ({
  editor,
  href,
  attrs,
  setLink: applyLink,
  unsetLink: removeLink,
  close: closeLink,
})
// Recompute link state from the live editor + drive the surface. Called from
// onSelectionUpdate + onUpdate (and after content sets). When the consumer slot is
// filled, re-render the reactive portal in place; otherwise refresh the built-in
// form's input value — but NOT while the user is typing in it (don't stomp mid-edit).
const refreshLink = () => {
  if (!editor) return
  const a = editor.getAttributes('link')
  const href = a.href || ''
  // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
  // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
  // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
  // change the link).
  const key = href + '' + JSON.stringify(a)
  if (key === lastLinkKey) return
  lastLinkKey = key
  $data.linkState = { href, attrs: a }
  if (linkEditorHandle) {
    linkEditorHandle.update(buildLinkScope(href, a))
  } else if (linkInputEl && !linkInputEl.matches(':focus')) {
    // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
    // the user typing in this input?" guard holds inside a shadow root — on the Lit
    // target document.activeElement is the shadow HOST, so a document.activeElement
    // check would always miss and stomp the user's in-progress URL. `:focus` is
    // per-element and shadow-boundary-agnostic.
    linkInputEl.value = href
  }
}
// Toolbar Link button (create affordance, ask C's deferred button): flip the
// open flag so the link-aware shouldShow surfaces the editor on the current
// selection, prefilled with any existing href. Declared AFTER refreshLink so its
// reactive dep array references an already-declared const (see order note above).
const openLinkEditor = () => {
  openFlag = true
  editor?.commands.focus()
  refreshLink()
  forceMenuRecheck()
}
// Build the batteries-included default link-editor form imperatively into the
// engine-managed host (the bubble-menu extension owns positioning). Vanilla DOM
// so it is byte-identical across all 6 targets and the framework never reconciles
// it. Enter = Apply, Escape = Cancel. Used ONLY when the `#linkEditor` slot is
// unfilled; a filled slot renders the consumer fragment via the reactive portal.
const buildDefaultLinkEditor = (el) => {
  const input = document.createElement('input')
  input.type = 'text'
  input.className = 'rozie-tiptap-link-input'
  input.placeholder = 'https://…'
  const apply = document.createElement('button')
  apply.type = 'button'
  apply.className = 'rozie-tiptap-link-apply'
  apply.textContent = 'Apply'
  const remove = document.createElement('button')
  remove.type = 'button'
  remove.className = 'rozie-tiptap-link-remove'
  remove.textContent = 'Remove'
  const cancel = document.createElement('button')
  cancel.type = 'button'
  cancel.className = 'rozie-tiptap-link-cancel'
  cancel.textContent = 'Cancel'
  // Keep the caret/selection in the document when a control is pressed (a plain
  // click would blur the editor and collapse the selection before the command runs).
  const keepFocus = (e) => e.preventDefault()
  for (const b of [apply, remove, cancel]) b.addEventListener('mousedown', keepFocus)
  apply.addEventListener('click', () => applyLink({ href: input.value }))
  remove.addEventListener('click', removeLink)
  cancel.addEventListener('click', closeLink)
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { e.preventDefault(); applyLink({ href: input.value }) }
    else if (e.key === 'Escape') { e.preventDefault(); closeLink() }
  })
  el.appendChild(input)
  el.appendChild(apply)
  el.appendChild(remove)
  el.appendChild(cancel)
  linkInputEl = input
}

// Recompute the character/word counter from the live editor (D-05). Robust to
// CharacterCount being absent (maxLength unset, no #count slot): reads
// `editor.storage.characterCount` when the extension is registered, else falls
// back to a plain text derivation so `getCharacterCount`/`getWordCount` and the
// #count slot's numbers are never stale.
const refreshCount = () => {
  if (!editor) return
  const storage = editor.storage.characterCount
  $data.count = {
    characters: storage ? storage.characters() : editor.getText().length,
    words: storage ? storage.words() : editor.getText().split(/\s+/).filter(Boolean).length,
  }
}

// ── StarterKit collision-aware config (ask A). StarterKit bundles several
// node/mark extensions INTERNALLY (invisible to a top-level array dedup) —
// e.g. its own `Link`. A consumer supplying a custom same-named extension via
// `extensions` therefore collides with StarterKit's copy and TipTap warns
// "Duplicate extension names found" while keeping BOTH; only
// `StarterKit.configure({ link:false })` actually disables StarterKit's. This
// map + helper make "consumer wins" true by auto-disabling the StarterKit key
// whenever the consumer supplies a same-named extension AND has not already
// decided that key's fate via the `starterKit` prop. Identity for the 15
// node/mark keys StarterKit exposes as `Partial<Options> | false`, plus the
// undo/redo option key `undoRedo` — mapped from BOTH its actual installed
// `.name` (`'undoRedo'`, verified against `@tiptap/extensions@3.23.5`) and the
// TipTap v2 alias `'history'` as a safety net for a consumer porting a v2
// History extension. Structural/plumbing StarterKit keys (document, text,
// dropcursor, gapcursor, listKeymap, trailingNode) are NOT node/mark
// replacements and are intentionally excluded.
const STARTERKIT_COLLISION_MAP = {
  bold: 'bold',
  italic: 'italic',
  strike: 'strike',
  code: 'code',
  heading: 'heading',
  paragraph: 'paragraph',
  blockquote: 'blockquote',
  codeBlock: 'codeBlock',
  hardBreak: 'hardBreak',
  horizontalRule: 'horizontalRule',
  bulletList: 'bulletList',
  orderedList: 'orderedList',
  listItem: 'listItem',
  link: 'link',
  underline: 'underline',
  undoRedo: 'undoRedo',
  history: 'undoRedo',
}

// Pure helper — returns `userConfig` extended so any StarterKit-bundled
// node/mark the consumer replaced (a same-named entry in `exts`) is disabled
// UNLESS the consumer already decided that key's fate in `userConfig` (an `in`
// presence check, so an explicit `false` OR an explicit options object both
// count as "consumer decided" — D-02, consumer wins unless configured
// explicitly). Never invokes consumer code — only reads `.name` and does key
// presence checks (guards a non-object/missing `.name` entry by skipping it).
const buildStarterKitConfig = (userConfig, exts) => {
  const effective = { ...userConfig }
  for (const ext of exts) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined
    if (typeof name !== 'string') continue
    const optionKey = STARTERKIT_COLLISION_MAP[name]
    if (optionKey && !(optionKey in effective)) effective[optionKey] = false
  }
  return effective
}

// Pure helper — D-03 last-wins safety net over the FINAL assembled extension
// array. Dedupes by `.name`, keeping the LAST occurrence (later = consumer).
// A nameless/unnamed entry is never collapsed against another nameless entry
// — each survives, keyed by a per-entry unique fallback rather than a shared
// `undefined` key.
const dedupeExtensionsByName = (exts) => {
  const byKey = new Map()
  let anonSeq = 0
  for (const ext of exts) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined
    const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`
    byKey.set(key, ext)
  }
  return [...byKey.values()]
}

// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator; generalized in Phase
// 260719-d9e / ask B). When the consumer fills the `nodeView` slot AND
// supplies one or more `nodeSpecs`, each spec becomes its own custom
// ProseMirror node rendering the SAME consumer fragment as a custom node
// *in-engine*, re-rendering it in place on every transaction via the
// reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). The fragment dispatches on `scope.node.type.name` to tell the
// specs apart (D-03 — single-slot-dispatch, no dynamic per-type slot names).
//
// A spec with NO `content` (typically `atom:true`) is a NON-EDITABLE node —
// no contentDOM — driven purely by selectNode/deselectNode/update(node) →
// handle.update so the fragment re-renders in place (engine-driven; no Rozie
// reactive loop). Proven originally by the @mention-chip recipe (Spike 009 /
// REQ-26), now shipped as a `nodeSpecs` entry in the example demos.
//
// A spec WITH `content` (e.g. `'inline*'`) is an EDITABLE BLOCK — it HAS a
// contentDOM. ProseMirror owns the editable hole; the consumer fragment
// renders chrome wrapping a [data-rozie-hole] placeholder and the per-target
// portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here. Proven
// originally by the editable-callout recipe (Spike 008 / REQ-23), now shipped
// as a `nodeSpecs` entry in the example demos.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv, spec) => (props) => {
  const { node, getPos, editor: ed } = props
  // hasContentDOM derives from the spec, not a bare boolean: an editable node
  // is one that is NOT an atom and declares `content` (e.g. 'inline*').
  const hasContentDOM = !spec.atom && !!spec.content
  // engine-owned outer host the consumer fragment mounts into.
  const dom = document.createElement(hasContentDOM ? 'div' : 'span')
  dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline'
  // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
  // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
  const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null
  if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content'

  const updateAttributes = (attrs) => {
    if (typeof getPos !== 'function') return
    const pos = getPos()
    if (pos == null) return
    ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }))
  }

  const buildScope = (n, selected) => ({
    node: n,
    selected,
    updateAttributes,
    getPos,
    editor: ed,
    ...(contentDOM ? { contentDOM } : {}),
  })

  // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
  // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
  const handle = nv(dom, buildScope(node, false))

  // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
  // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
  // ProseMirror manages `contentDOM` and renders the node's editable children
  // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
  // rendered into `dom` by the per-target reactive portal — synchronously on
  // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
  // Angular (REQ-23). A query-after-render graft (retried across a microtask +
  // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
  // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
  // and the framework never reconciles it away (the hole carries no child binding).
  const graftContentDOM = (attempt) => {
    if (!contentDOM) return
    const hole = dom.querySelector('[data-rozie-hole]')
    if (hole) {
      if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM)
      return
    }
    if (attempt < 5) {
      if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1))
      else requestAnimationFrame(() => graftContentDOM(attempt + 1))
    }
  }
  graftContentDOM(0)

  // After a reactive re-render (chrome update), re-graft so a fragment that
  // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
  // (REQ-24 — the editable subtree survives every chrome update).
  const updateInPlace = (n, selected) => {
    handle.update(buildScope(n, selected))
    if (contentDOM) graftContentDOM(0)
  }

  return {
    dom,
    ...(contentDOM ? { contentDOM } : {}),
    // attr / content change for THIS node → re-render the fragment in place,
    // keep the view (return true). The new node identity is forwarded so the
    // fragment reads fresh node.attrs (REQ-26).
    update(nextNode) {
      if (nextNode.type !== node.type) return false
      updateInPlace(nextNode, false)
      return true
    },
    // NodeSelection enters/leaves the node → toggle `selected` in scope so the
    // chip's selected styling is pure engine-driven reactive `update`.
    selectNode() {
      updateInPlace(node, true)
    },
    deselectNode() {
      updateInPlace(node, false)
    },
    destroy() {
      handle.dispose()
    },
  }
}

// Pure helper (ask B, D-02) — extracts { el, attr, value } from a parseHTML
// tag selector string, e.g. 'span[data-x]' → { el: 'span', attr: 'data-x',
// value: '' } or 'div[data-x=y]' → { el: 'div', attr: 'data-x', value: 'y' }.
// Drives renderHTML's marker attribute so the serialized element reproduces
// the exact shape the parseHTML rule expects. MUST NOT throw on a
// malformed/empty selector (T-d9e-01 — a bad selector degrades only that one
// node's render, never crashes the editor): falls back to el = the raw
// selector (or 'span' if falsy), attr = null (no marker), value = ''.
const parseTagSelector = (selector) => {
  const raw = typeof selector === 'string' ? selector : ''
  const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/)
  const el = elMatch ? elMatch[0] : (raw || 'span')
  const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/)
  if (!attrMatch) return { el, attr: null, value: '' }
  const attr = (attrMatch[1] ?? '').trim()
  const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ''
  return { el, attr, value }
}

// Build ONE custom Node per consumer-supplied spec, all bound to the SAME
// reactive nodeView portal (ask B, D-02). Takes the per-target
// `$portals.nodeView` (captured here so the reference stays inside the mount
// lifecycle — never top-level, per the bundled-leaf typecheck rule) and the
// `nodeSpecs` prop array (read once at mount — setup-once, not reactive).
const makeNodeViewExtensions = (nv, specs) => specs.map((spec) => {
  // hasContentDOM decides the renderHTML hole: an editable (non-atom,
  // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
  // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
  // a leaf node spec" otherwise).
  const hasContentDOM = !spec.atom && !!spec.content
  const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag
  const { el, attr, value } = parseTagSelector(firstTag)
  return Node.create({
    name: spec.name,
    group: spec.group ?? 'block',
    inline: spec.inline ?? false,
    atom: spec.atom ?? false,
    selectable: spec.selectable ?? true,
    defining: spec.defining ?? false,
    ...(spec.content ? { content: spec.content } : {}),
    addAttributes: () => spec.attrs ?? {},
    parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t) => ({ tag: t })),
    renderHTML: ({ HTMLAttributes }) =>
      hasContentDOM
        ? [el, { ...(attr ? { [attr]: value } : {}), ...HTMLAttributes }, 0]
        : [el, { ...(attr ? { [attr]: value } : {}), ...HTMLAttributes }],
    addNodeView: () => makeNodeView(nv, spec),
  })
})

// Shared image-file finder for the upload handlers below — the first
// `image/*` File in a FileList, else undefined. Guards a missing FileList.
const findImageFile = (files) => {
  if (!files) return undefined
  for (let i = 0; i < files.length; i++) {
    const f = files[i]
    if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f
  }
  return undefined
}

// uploadImage paste/drop fallbacks (ask D / D-04) — ProseMirror `editorProps`
// handlers. TOP-LEVEL functions (siblings of `refreshActive`/the $expose
// verbs below), NOT nested inside $onMount's ternary/object-literal — a
// closure reading the component-scope `editor` from several function-levels
// deep inside $onMount (object-literal method → `.then` callback) hits a
// `this`-rebinding gap on the class-based targets (Angular/Lit) that the
// emitter's nested-`this` repair does not reach at that depth
// (emitter-backlog). A top-level function is only ONE level removed from the
// promoted-`this` boundary — the same shallow depth as the `onUpdate` /
// `$watch` callbacks elsewhere in this file, which already compile clean —
// so referencing `editor` here needs no repair at all. Each handler claims
// ONLY an image/* payload: returns `true` SYNCHRONOUSLY (claiming the
// paste/drop now — never awaits inside the handler) and inserts the resolved
// URL once the consumer's uploadImage promise settles; a rejection is
// swallowed (`.catch(() => {})`) so a failed upload never crashes the editor
// (T-e7i-01). Returns `false` for a non-image payload — or, for drop, an
// internal node move — so ProseMirror (or a consumer editorProps handler,
// which still wins via the LAST spread) processes it normally.
function handlePaste(view, event, slice) {
  // Captured into a local (not repeated `$props.uploadImage` member reads) so
  // the null-check narrows the type on every target — including Lit, where
  // the Function prop lowers to a nullable function type and a bare
  // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
  // typecheck (TS2721) even though this handler is only ever wired into
  // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
  // gate already guarantees this in practice).
  const upload = $props.uploadImage
  if (!upload) return false
  const file = findImageFile(event.clipboardData ? event.clipboardData.files : undefined)
  if (!file) return false
  event.preventDefault()
  upload(file)
    .then((url) => { editor?.chain().focus().setImage({ src: url }).run() })
    .catch(() => {})
  return true
}
function handleDrop(view, event, slice, moved) {
  if (moved) return false
  // See handlePaste — local capture for the same cross-target null-narrowing.
  const upload = $props.uploadImage
  if (!upload) return false
  const file = findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined)
  if (!file) return false
  event.preventDefault()
  const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
  upload(file)
    .then((url) => {
      const insertPos = pos ? pos.pos : (editor ? editor.state.selection.head : 0)
      editor?.chain().focus().insertContentAt(insertPos, { type: 'image', attrs: { src: url } }).run()
    })
    .catch(() => {})
  return true
}

$onMount(() => {
  lastHtml = $props.html

  // Register the reactive node-view nodes ONLY when the consumer fills the
  // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
  // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
  // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
  // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
  // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
  // here inside the mount body and passed into the node factory, keeping the
  // reference scoped to the mount lifecycle (the toolbar-slot discipline).
  const nodeViewExtensions = ($slots.nodeView && $props.nodeSpecs.length) ? makeNodeViewExtensions($portals.nodeView, $props.nodeSpecs) : []

  // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
  // (setup-once, like content/editable/autofocus — no reactivity required). The
  // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
  // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
  // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
  // (in the style block) paints the ghost text. Empty placeholder = no extension.
  const placeholderExtensions = $props.placeholder ? [Placeholder.configure({ placeholder: $props.placeholder })] : []

  // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
  // Floating-UI menu extension needs its host `element` at construction time. Each
  // menu's host element is created imperatively (the nodeView discipline — the
  // engine owns positioning; the consumer fragment is portalled in AFTER mount).
  // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
  //
  // The host elements are created up front (when filled) so they're captured into
  // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
  // portal mount; the extension list is then assembled by conditional SPREAD (NOT
  // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
  // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
  // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
  if ($slots.bubbleMenu) {
    bubbleMenuEl = document.createElement('div')
    bubbleMenuEl.className = 'rozie-tiptap-bubble-menu'
  }
  if ($slots.floatingMenu) {
    floatingMenuEl = document.createElement('div')
    floatingMenuEl.className = 'rozie-tiptap-floating-menu'
  }
  // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
  // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
  // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
  // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
  // the link-editor shouldShow below — a live check that follows a runtime toggle.
  // This closes both directions of the mount-time-gate bug: a doc mounted readonly
  // that later becomes editable gets a working link editor, and a doc toggled TO
  // readonly can no longer be link-edited (isEditable false → never shows, so no
  // Apply/Remove on a read-only document).
  linkEditorEl = document.createElement('div')
  linkEditorEl.className = 'rozie-tiptap-link-editor'
  // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
  // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
  // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
  // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
  // (non-empty text selection). The link editor's shouldShow is link-aware: show
  // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
  // by design so it never fires on a bare selection and collide with the general one.
  const menuExtensions = [
    ...(bubbleMenuEl ? [BubbleMenu.configure({
      pluginKey: 'rozieBubbleMenu',
      element: bubbleMenuEl,
      ...($props.bubbleMenuShouldShow ? { shouldShow: $props.bubbleMenuShouldShow } : {}),
    })] : []),
    ...(floatingMenuEl ? [FloatingMenu.configure({ element: floatingMenuEl })] : []),
    ...(linkEditorEl ? [BubbleMenu.configure({
      pluginKey: 'rozieLinkEditor',
      element: linkEditorEl,
      // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
      // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
      // button set openFlag (create) — never on a bare selection.
      shouldShow: ({ editor }) => editor.isEditable && (editor.isActive('link') || openFlag),
    })] : []),
  ]

  // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
  // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
  // absent: no Image extension, no paste/drop handlers (zero overhead, the
  // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
  // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
  const imageExtensions = $props.uploadImage ? [Image] : []

  // Character/word count (D-01..D-03). Gated on maxLength being set OR the
  // `count` slot being filled — a stock <TipTap> with neither registers NO
  // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
  // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
  // cap); otherwise CharacterCount tracks with no limit (soft — overflow
  // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
  // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
  // never[]-inference reason as placeholderExtensions/imageExtensions.
  const needsCount = $props.maxLength != null || $slots.count
  const characterCountExtensions = needsCount
    ? [CharacterCount.configure(($props.enforceMaxLength && $props.maxLength != null) ? { limit: $props.maxLength } : {})]
    : []

  // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
  // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
  // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
  // functions declared above (see their doc comment for why they live at the
  // top level rather than as closures nested in this ternary).
  const uploadHandlers = $props.uploadImage ? { handlePaste, handleDrop } : {}

  editor = new Editor({
    element:    $refs.editorEl,
    content:    $props.html,
    editable:   $props.editable,
    autofocus:  $props.autofocus,
    // StarterKit first (config-disabled per the collision scan below); the
    // Placeholder ext next; the reactive node-view nodes next; consumer
    // extensions LAST so they win (TipTap applies later-registered extensions
    // over earlier ones for the same node/mark) — and the whole array is
    // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
    // of the config-level auto-disable (D-02), which is what actually silences
    // StarterKit's internal same-named extension (e.g. its bundled `Link`).
    extensions: dedupeExtensionsByName([
      StarterKit.configure(buildStarterKitConfig($props.starterKit, $props.extensions)),
      ...placeholderExtensions,
      ...nodeViewExtensions,
      ...menuExtensions,
      ...imageExtensions,
      ...characterCountExtensions,
      ...$props.extensions,
    ]),
    editorProps: {
      attributes: {
        'aria-label': $props.ariaLabel,
        ...($props.editorClass ? { class: $props.editorClass } : {}),
        ...($props.placeholder ? { 'data-placeholder': $props.placeholder, 'aria-placeholder': $props.placeholder } : {}),
      },
      // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
      // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
      // `{}` (no-op) when $props.uploadImage is unset.
      ...uploadHandlers,
      // Consumer editorProps spread LAST — full ProseMirror editorProps control
      // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
      ...$props.editorProps,
    },
    onUpdate: ({ editor }) => {
      const next = editor.getHTML()
      lastHtml = next
      // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
      if (next !== $props.html) $model.html = next
      refreshCount()
      refreshLink()
      $emit('update', next)
    },
    onSelectionUpdate: () => {
      refreshActive()
      refreshLink()
      $emit('selectionUpdate')
    },
    onFocus: () => $emit('focus'),
    onBlur:  ({ event }) => {
      // Clear the create-mode latch when focus truly leaves the editor + its link
      // surface — but NOT when it moves INTO the link editor host (clicking the URL
      // input blurs the editor; the buttons are already covered by their keepFocus
      // mousedown). Without this, openFlag stays true after the user dismisses the
      // create affordance by clicking away, so the editor spuriously re-surfaces on
      // the next unrelated selection.
      const to = event && event.relatedTarget
      if (!(to instanceof Node && linkEditorEl && linkEditorEl.contains(to))) openFlag = false
      $emit('blur')
    },
  })
  refreshActive()
  refreshCount()
  refreshLink()

  // `toolbar` portal slot — when the consumer fills it, mount their toolbar
  // fragment into the engine-adjacent host node, handing them the live editor
  // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
  // referenced ONLY here inside $onMount (the per-target portal helper is scoped
  // to the mount lifecycle — a top-level reference would fail the bundled-leaf
  // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
  // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
  if ($slots.toolbar && $refs.toolbarEl) {
    toolbarDispose = $portals.toolbar($refs.toolbarEl, { editor })
  }

  // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
  // fragment into the engine-owned (imperatively-created) host element handed to
  // the Floating-UI menu extension, with the live editor in scope (their buttons
  // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
  // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
  // strict-typecheck discipline). The element is created above only when the slot
  // is filled, so each portal fires exactly when its slot exists.
  if (bubbleMenuEl) {
    bubbleMenuDispose = $portals.bubbleMenu(bubbleMenuEl, { editor })
  }
  if (floatingMenuEl) {
    floatingMenuDispose = $portals.floatingMenu(floatingMenuEl, { editor })
  }

  // Link editor (#2) — mount the surface into its engine-managed host. When the
  // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
  // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
  // this survives the bubble-menu extension's detach-reattach). Otherwise the
  // component's own default form is built imperatively into the same host.
  // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
  if (linkEditorEl) {
    if ($slots.linkEditor) {
      // Read the initial link attrs straight off the live editor (NOT
      // `$data.linkState`, written by the refreshLink() call above in this
      // same tick) — the same React stale-read avoidance as buildLinkScope's
      // other call site.
      const initialLinkAttrs = editor.getAttributes('link')
      linkEditorHandle = $portals.linkEditor(linkEditorEl, buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs))
    } else {
      buildDefaultLinkEditor(linkEditorEl)
      // Prefill correction (D-04): the refreshLink() call above (right after
      // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
      // exist yet at that point, so every LATER refreshLink() for the same
      // link early-returns, leaving the just-created input empty even when the
      // caret starts inside a link. Seed it directly from the LIVE editor
      // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
      // $data key immediately after refreshLink() just wrote it hits the
      // React setState-is-async stale-read trap (the same write-then-read-in-
      // one-handler class ROZ138 warns about elsewhere in this file), since
      // $data.linkState was written by the refreshLink() call directly above.
      // `editor` is a plain instance handle, not reactive state, so reading it
      // straight off the engine is synchronous and target-uniform. A no-link
      // mount leaves this the empty string (unchanged).
      if (linkInputEl) linkInputEl.value = editor.getAttributes('link').href || ''
    }
  }

  return () => {
    toolbarDispose?.()
    toolbarDispose = null
    bubbleMenuDispose?.()
    bubbleMenuDispose = null
    floatingMenuDispose?.()
    floatingMenuDispose = null
    linkEditorHandle?.dispose()
    linkEditorHandle = null
    linkEditorEl = null
    linkInputEl = null
    editor?.destroy()
  }
})

// Reconcile EXTERNAL prop changes back into the editor without bouncing through
// onUpdate. The guard compares against `lastHtml` (the value the editor was last
// set with) — NOT `editor.getHTML()` (ProseMirror's normalized serialization,
// which never string-equals the author's raw HTML, so a getHTML() guard would
// re-run setContent on every mount and reset the selection). setContent's
// { emitUpdate: false } skips the change emission (TipTap v3 — the 2nd arg is an
// options object, not a bare boolean as in v2).
$watch(() => $props.html, (v) => {
  if (!editor) return
  if (v === lastHtml) return
  lastHtml = v
  editor.commands.setContent(v, { emitUpdate: false })
  refreshActive()
  refreshCount()
  refreshLink()
})

// setEditable's 2nd arg is `emitUpdate` (defaults to true in TipTap v3). Pass
// `false` — toggling editability is not a content change and must NOT emit an
// `update`, which would round-trip ProseMirror's normalized HTML back into the
// bound model.
$watch(() => $props.editable, (v) => editor?.setEditable(v, false))

// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 25 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
//   - The content setter is named `setContent`, NOT `setHtml` — an `html` model
//     prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
//     $expose verb would collide on the React target (ROZ524). (CodeMirror's
//     setValue→replaceValue lesson, html edition.)
//   - None of the 25 names collide with LitElement reserved lifecycle methods
//     (update/render/firstUpdated/updated/willUpdate/requestUpdate).
//   - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
//     `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
//     class-based targets (Angular) an output field and a method cannot share a
//     name (ROZ121). The diagnostic's own guidance: rename the method, keep the
//     event's public name. (The expose-verb-vs-event-name collision lesson.)
//   - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
//     ariaLabel/editorProps/extensions).
function getEditor()        { return editor }
function focusEditor()      { editor?.commands.focus() }
function blurEditor()       { editor?.commands.blur() }
function getHTML()          { return editor ? editor.getHTML() : '' }
function getJSON()          { return editor ? editor.getJSON() : null }
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText()          { return editor ? editor.getText() : '' }
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next) {
  if (!editor) return
  const v = next ?? ''
  if (v === lastHtml) return
  lastHtml = v
  editor.commands.setContent(v, { emitUpdate: false })
  $model.html = v
  refreshActive()
  refreshCount()
  refreshLink()
}
function clearContent() {
  if (!editor) return
  editor.commands.clearContent()
  lastHtml = editor.getHTML()
  $model.html = lastHtml
  refreshActive()
  refreshCount()
  refreshLink()
}
function toggleBold()       { editor?.chain().focus().toggleBold().run();   refreshActive() }
function toggleItalic()     { editor?.chain().focus().toggleItalic().run(); refreshActive() }
function toggleHeading(level) {
  editor?.chain().focus().toggleHeading({ level: level ?? 1 }).run()
  refreshActive()
}
function toggleBulletList() { editor?.chain().focus().toggleBulletList().run(); refreshActive() }
function toggleUnderline()  { editor?.chain().focus().toggleUnderline().run(); refreshActive() }
function toggleOrderedList() { editor?.chain().focus().toggleOrderedList().run(); refreshActive() }
function undo()             { editor?.chain().focus().undo().run(); refreshActive() }
function redo()             { editor?.chain().focus().redo().run(); refreshActive() }
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain()            { return editor ? editor.chain().focus() : null }
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
//   - isActive(name, attrs?): is a mark/node active in the current selection
//     (drive toolbar button active styling). False before mount.
//   - can(): the command-availability chain (editor.can().chain()…run()) for
//     enable/disable of toolbar buttons. null before mount (mirrors chain()).
//   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name, attrs) { return editor ? editor.isActive(name, attrs) : false }
function can()                 { return editor ? editor.can() : null }
function isEmpty()             { return editor ? editor.isEmpty : true }
// Character/word count reads (D-04). Prefer the CharacterCount extension's live
// storage when registered (maxLength set or #count slot filled); otherwise a
// text-based fallback so these ALWAYS return a number — 0 before mount, and a
// correct count even on a stock <TipTap> that never registered CharacterCount.
function getCharacterCount() { if (!editor) return 0; return editor.storage.characterCount ? editor.storage.characterCount.characters() : editor.getText().length }
function getWordCount()      { if (!editor) return 0; return editor.storage.characterCount ? editor.storage.characterCount.words() : editor.getText().split(/\s+/).filter(Boolean).length }
// setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
// applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
// (buildLinkScope above), so the imperative handle and the slot-scope verb
// implementation cannot disagree. Four-way collision check:
//   - not a prop name — the 14 props are html / editable / placeholder /
//     autofocus / editorClass / ariaLabel / editorProps / extensions /
//     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
//     bubbleMenuShouldShow;
//   - not an emitted event name — the 4 events are update / selectionUpdate /
//     focus / blur (the ROZ121 Angular output-field-vs-method rule);
//   - not an existing $expose verb — the 23 names already in the object below;
//   - not a React auto-generated model setter — the only model prop is
//     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
//     `setContent`), and not a LitElement lifecycle method (update / render /
//     firstUpdated / updated / willUpdate / requestUpdate).
// applyLink already ignores an attrs object without a non-empty string href
// (no degenerate empty-href anchor is ever written), and both verbs no-op
// before mount / after destroy through the `editor?.` guards already inside
// applyLink/removeLink — no second validation path is introduced.
function setLink(attrs)      { applyLink(attrs) }
function unsetLink()         { removeLink() }

$expose({
  getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent,
  toggleBold, toggleItalic, toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList, undo, redo, chain,
  isActive, can, isEmpty, getCharacterCount, getWordCount, openLinkEditor, setLink, unsetLink,
})
</script>

<template>
<div class="rozie-tiptap" :class="{ 'is-readonly': !$props.editable }">
  <!-- Internal batteries-included toolbar — rendered when editable AND the
       consumer has NOT supplied a `toolbar` slot. Buttons drive the $expose
       command verbs directly; active state tracks editor.isActive() live. -->
  <div class="rozie-tiptap-toolbar" r-if="$props.editable && !$slots.toolbar">
    <button type="button" :class="{ active: $data.active.bold }"       @click="toggleBold"        aria-label="Bold"><strong>B</strong></button>
    <button type="button" :class="{ active: $data.active.italic }"     @click="toggleItalic"      aria-label="Italic"><em>I</em></button>
    <span class="sep" />
    <button type="button" :class="{ active: $data.active.h1 }"         @click="toggleHeading(1)"  aria-label="Heading 1">H1</button>
    <button type="button" :class="{ active: $data.active.h2 }"         @click="toggleHeading(2)"  aria-label="Heading 2">H2</button>
    <span class="sep" />
    <button type="button" :class="{ active: $data.active.bulletList }" @click="toggleBulletList"  aria-label="Bullet list">• List</button>
    <button type="button" :class="{ active: $data.active.underline }" @click="toggleUnderline"    aria-label="Underline"><u>U</u></button>
    <button type="button" :class="{ active: $data.active.orderedList }" @click="toggleOrderedList" aria-label="Ordered list">1. List</button>
    <span class="sep" />
    <button type="button" :class="{ active: $data.active.link }" @click="openLinkEditor" aria-label="Link">Link</button>
    <span class="sep" />
    <button type="button" @click="undo" aria-label="Undo">↺</button>
    <button type="button" @click="redo" aria-label="Redo">↻</button>
  </div>
  <!-- Consumer toolbar portal host — rendered when editable AND the `toolbar`
       slot is filled. $portals.toolbar (in $onMount) mounts the consumer's
       fragment here with the live editor in scope. -->
  <div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" ref="toolbarEl" r-if="$props.editable && $slots.toolbar"></div>
  <div ref="editorEl" class="rozie-tiptap-content" :data-placeholder="$props.placeholder" />
  <!-- Character/word counter (D-05/D-06, quick 260720-tzw). r-if-gated so a
       stock <TipTap> (no maxLength, no #count slot) renders ZERO markup here —
       zero overhead, no VR drift. A PLAIN reactive scoped slot (RD-01, the
       combobox/listbox `<slot name="option" :option=… >` pattern) whose
       DEFAULT content is the built-in counter; a filled #count slot overrides
       the display, receiving the SAME reactive numbers. Scope-key names are
       authored directly in camelCase (`:maxLength`, not `:max-length`) —
       lowerSlots reads the binding attribute name verbatim with no
       kebab->camel step, matching this file's/the repo's existing multi-word
       binding convention (`:columnId`-style bindings elsewhere). ROZ127-clean:
       `count` ≠ any prop name (html/editable/placeholder/autofocus/
       editorClass/ariaLabel/editorProps/extensions/starterKit/nodeSpecs/
       uploadImage/maxLength/enforceMaxLength). -->
  <div class="rozie-tiptap-count" r-if="$props.maxLength != null || $slots.count">
    <slot name="count" :characters="$data.count.characters" :words="$data.count.words" :maxLength="$props.maxLength" :over="$props.maxLength != null && $data.count.characters > $props.maxLength">
      <span class="rozie-tiptap-count-value" :class="{ over: $props.maxLength != null && $data.count.characters > $props.maxLength }">{{ $data.count.characters }} / {{ $props.maxLength }}</span>
    </slot>
  </div>
</div>
<!--
  Portal-slot primitive (Spike 003). The `toolbar` slot is declared but NOT
  rendered inline — 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 it from script via
  $portals.toolbar($refs.toolbarEl, { editor }) inside $onMount; the portal
  helper mounts the consumer's fragment into the toolbar host node and returns a
  dispose handle the wrapper calls on unmount. ROZ127-clean: `toolbar` ≠ any prop.
-->
<slot name="toolbar" portal :params="['editor']" />
<!--
  Selection-anchored menu portal slots (G2). Same mount-once portal shape as
  `toolbar` (NO `reactive`) — but with NO template host div: the host element is
  created imperatively in $onMount and handed to the Floating-UI menu extension
  (@tiptap/extension-bubble-menu / -floating-menu), which owns the element's
  positioning and appends it to the editor's parent. The wrapper invokes
  $portals.bubbleMenu / $portals.floatingMenu(menuEl, { editor }) inside $onMount,
  mounting the consumer's menu fragment into that host node, and returns a dispose
  handle called on unmount. The default `shouldShow` shows the bubble menu on a
  non-empty text selection and the floating menu on an empty line.

  ROZ127-clean: `bubbleMenu` / `floatingMenu` ≠ any prop name (html/editable/
  placeholder/autofocus/editorClass/ariaLabel/editorProps/extensions).
-->
<slot name="bubbleMenu" portal :params="['editor']" />
<slot name="floatingMenu" portal :params="['editor']" />
<!--
  Link editor override slot (#2 — reactive). Declared but NOT rendered inline (per-
  target emitters skip it); it exists to declare the consumer-facing render-prop /
  scoped-slot / contentChild shape. When filled, the consumer fragment renders in
  the link-editor's engine-managed bubble-menu host INSTEAD of the built-in form,
  re-rendered in place on every selection change via the reactive
  $portals.linkEditor(dom, scope) => { update, dispose } handle (Spike 016 proved a
  reactive portal survives the bubble-menu extension's detach-reattach). Scope:
    - editor — the live editor.
    - href — the current link's href ('' when none).
    - attrs — the current link mark's attrs object (custom attrs like data-course-link).
    - setLink(attrs) — apply a link with arbitrary attrs (extendMarkRange('link') +
      setLink); forwarded VERBATIM (persisting a custom attr needs the consumer's
      Link.extend — REQ-42).
    - unsetLink() — remove the link.
    - close() — dismiss the editor.

  ROZ127-clean: `linkEditor` ≠ any prop name (html/editable/placeholder/autofocus/
  editorClass/ariaLabel/editorProps/extensions/starterKit/nodeSpecs/uploadImage/
  maxLength/enforceMaxLength/bubbleMenuShouldShow).
-->
<slot name="linkEditor" portal reactive :params="['editor', 'href', 'attrs', 'setLink', 'unsetLink', 'close']" />
<!--
  Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive` portal
  slot; generalized in Phase 260719-d9e / ask B). Declared but NOT rendered
  inline; per-target template emitters skip it. It exists to declare the
  consumer-facing render-prop / scoped-slot / contentChild shape (per target)
  for CUSTOM ProseMirror NODEs registered via the `nodeSpecs` prop. Invoked
  from the addNodeView closures via $portals.nodeView(dom, scope) where scope
  carries the live node state; the per-target portal bridge mounts the SAME
  consumer fragment (dispatching on `scope.node.type.name`) into each
  engine-owned node DOM and returns a { update, dispose } handle (the reactive
  variant — re-renders IN PLACE on every transaction, no remount).

  For a spec declaring `content` (an EDITABLE node), the consumer fragment
  renders chrome WRAPPING a `[data-rozie-hole]` placeholder element; the
  per-target bridge grafts the engine-owned `contentDOM` (in scope) into that
  hole — native ref on React/Solid/Lit, querySelector-after-render on
  Vue/Svelte/Angular (REQ-23). ProseMirror then owns + manages the editable
  subtree; the framework must never reconcile it away. For a spec with NO
  `content` (typically a NON-EDITABLE atom), `contentDOM` is absent and the
  fragment is purely a reactive read of `node.attrs` + `selected` (REQ-26).

  ROZ127-clean: `nodeView` ≠ any prop name (html/editable/placeholder/autofocus/
  editorClass/ariaLabel/editorProps/extensions/starterKit/nodeSpecs).
-->
<slot name="nodeView" portal reactive :params="['node', 'selected', 'updateAttributes', 'getPos', 'editor', 'contentDOM']" />
</template>

<style>
/*
  Token-driven (mirrors combobox/dialog/command-palette themes): every visual
  value is a `var(--rozie-tiptap-*, <fallback>)`. Structural behavior (flex
  layout, overflow, cursor) is not tokenized.
*/
.rozie-tiptap {
  border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rozie-tiptap-radius, 6px);
  overflow: hidden;
  background: var(--rozie-tiptap-bg, white);
}
.rozie-tiptap.is-readonly {
  background: var(--rozie-tiptap-readonly-bg, #fafafa);
}

.rozie-tiptap-toolbar {
  display: flex;
  align-items: center;
  gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
  padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
  border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
  background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
}
.rozie-tiptap-toolbar button {
  padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
  border: var(--rozie-tiptap-button-border, 1px solid transparent);
  background: var(--rozie-tiptap-button-bg, transparent);
  border-radius: var(--rozie-tiptap-button-radius, 3px);
  cursor: pointer;
  font: inherit;
  font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
  min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
  color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
}
.rozie-tiptap-toolbar button:hover {
  background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-tiptap-toolbar button.active {
  background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
  color: var(--rozie-tiptap-button-active-color, white);
  border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
}
.rozie-tiptap-toolbar .sep {
  width: var(--rozie-tiptap-toolbar-sep-width, 1px);
  height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
  background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
  margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
}

.rozie-tiptap-content {
  padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
  min-height: var(--rozie-tiptap-content-min-height, 6rem);
  font: inherit;
  outline: none;
}
.rozie-tiptap-content p { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
.rozie-tiptap-content p:last-child { margin-bottom: 0; }
.rozie-tiptap-content h1 { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content h2 { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content ul { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }

.rozie-tiptap-count {
  display: flex;
  justify-content: flex-end;
  padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
  border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
  font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
  color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
}
.rozie-tiptap-count-value.over {
  color: var(--rozie-tiptap-count-over-color, #c0392b);
}

/* Placeholder ghost-text (G3) via the :root { } engine-DOM escape hatch (Phase
   34). The Placeholder extension adds `.is-editor-empty` + a `data-placeholder`
   attribute to the first empty ProseMirror node — an engine-rendered node that
   never carries Rozie's [data-rozie-s-*] scope attribute, so a plain scoped rule
   would silently fail to match on React/Solid/Lit. The nested `:root { }` form
   emits its children UNSCOPED/global on all six targets, reaching the engine node.
   (Not `:global()` — that is a ROZ128 hard error; `:root { nested }` is canonical.) */
:root {
  .rozie-tiptap-content .is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
    float: left;
    height: 0;
    pointer-events: none;
  }
}

/* Link editor (#2) surface. Its host (`.rozie-tiptap-link-editor`) is created
   imperatively and appended by the bubble-menu extension OUTSIDE Rozie's scoped
   subtree — an engine-owned node that never carries a [data-rozie-s-*] scope
   attribute, so (like the placeholder) these rules live in the `:root { }`
   engine-DOM escape hatch to emit UNSCOPED/global on all six targets. Token-driven
   per the #3 convention: every visual value is a `var(--rozie-tiptap-link-*, …)`.
   The host styling wraps BOTH the built-in form and a consumer `#linkEditor`
   fragment; the input/button rules style only the built-in default form. */
:root {
  .rozie-tiptap-link-editor {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-link-gap, 0.25rem);
    padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
    background: var(--rozie-tiptap-link-bg, #1a1a1a);
    border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rozie-tiptap-link-radius, 6px);
    box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
  }
  .rozie-tiptap-link-input {
    font: inherit;
    font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
    min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
    border: var(--rozie-tiptap-link-input-border, 1px solid #444);
    border-radius: var(--rozie-tiptap-link-input-radius, 4px);
    background: var(--rozie-tiptap-link-input-bg, #fff);
    color: var(--rozie-tiptap-link-input-color, #000);
  }
  .rozie-tiptap-link-editor button {
    font: inherit;
    font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
    border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
    border-radius: var(--rozie-tiptap-link-button-radius, 4px);
    background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
    color: var(--rozie-tiptap-link-button-color, #fff);
    cursor: pointer;
  }
  .rozie-tiptap-link-editor button:hover {
    background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
  }
  .rozie-tiptap-link-editor .rozie-tiptap-link-remove {
    color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
  }
}
</style>

</rozie>

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

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { clsx, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './TipTap.css';
import './TipTap.global.css';
import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';

// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).

interface CountCtx { characters: any; words: any; maxLength: any; over: any; }

interface ToolbarCtx { editor: any; }

interface BubbleMenuCtx { editor: any; }

interface FloatingMenuCtx { editor: any; }

interface LinkEditorCtx { editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any; }

interface NodeViewCtx { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any; }

interface TipTapProps {
  /**
   * The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
   * @example
   * <TipTap r-model:html="content" placeholder="Start writing…" />
   */
  html?: string;
  defaultHtml?: string;
  onHtmlChange?: (html: string) => void;
  /**
   * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
   */
  editable?: boolean;
  /**
   * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
   */
  placeholder?: string;
  /**
   * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
   */
  autofocus?: boolean;
  /**
   * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
   */
  editorClass?: string;
  /**
   * The accessible name (`aria-label`) applied to the contenteditable element.
   */
  ariaLabel?: string;
  /**
   * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
   */
  editorProps?: Record<string, any>;
  /**
   * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
   */
  extensions?: any[];
  /**
   * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
   */
  starterKit?: Record<string, any>;
  /**
   * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
   * @example
   * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
   */
  nodeSpecs?: any[];
  /**
   * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
   * @example
   * <TipTap :upload-image="uploadFn" />
   */
  uploadImage?: ((...args: any[]) => any) | null;
  /**
   * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
   * @example
   * <TipTap :max-length="500" />
   */
  maxLength?: (number) | null;
  /**
   * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
   */
  enforceMaxLength?: boolean;
  /**
   * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
   * @example
   * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
   */
  bubbleMenuShouldShow?: ((...args: any[]) => any) | null;
  onUpdate?: (...args: any[]) => void;
  onSelectionUpdate?: (...args: any[]) => void;
  onFocus?: (...args: any[]) => void;
  onBlur?: (...args: any[]) => void;
  renderCount?: (ctx: CountCtx) => ReactNode;
  renderToolbar?: (ctx: ToolbarCtx) => ReactNode;
  renderBubbleMenu?: (ctx: BubbleMenuCtx) => ReactNode;
  renderFloatingMenu?: (ctx: FloatingMenuCtx) => ReactNode;
  renderLinkEditor?: (ctx: LinkEditorCtx) => ReactNode;
  renderNodeView?: (ctx: NodeViewCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface TipTapHandle {
  getEditor: (...args: any[]) => any;
  focusEditor: (...args: any[]) => any;
  blurEditor: (...args: any[]) => any;
  getHTML: (...args: any[]) => any;
  getJSON: (...args: any[]) => any;
  getText: (...args: any[]) => any;
  setContent: (...args: any[]) => any;
  clearContent: (...args: any[]) => any;
  toggleBold: (...args: any[]) => any;
  toggleItalic: (...args: any[]) => any;
  toggleHeading: (...args: any[]) => any;
  toggleBulletList: (...args: any[]) => any;
  toggleUnderline: (...args: any[]) => any;
  toggleOrderedList: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  chain: (...args: any[]) => any;
  isActive: (...args: any[]) => any;
  can: (...args: any[]) => any;
  isEmpty: (...args: any[]) => any;
  getCharacterCount: (...args: any[]) => any;
  getWordCount: (...args: any[]) => any;
  openLinkEditor: (...args: any[]) => any;
  setLink: (...args: any[]) => any;
  unsetLink: (...args: any[]) => any;
}

const TipTap = forwardRef<TipTapHandle, TipTapProps>(function TipTap(_props: TipTapProps, ref): JSX.Element {
  const portalRoots = useRef<Set<Root>>(new Set());
  const __defaultEditorProps = useState(() => (() => ({}))())[0];
  const __defaultExtensions = useState(() => (() => [])())[0];
  const __defaultStarterKit = useState(() => (() => ({}))())[0];
  const __defaultNodeSpecs = useState(() => (() => [])())[0];
  const props: Omit<TipTapProps, 'editable' | 'placeholder' | 'autofocus' | 'editorClass' | 'ariaLabel' | 'editorProps' | 'extensions' | 'starterKit' | 'nodeSpecs' | 'uploadImage' | 'maxLength' | 'enforceMaxLength' | 'bubbleMenuShouldShow'> & { editable: boolean; placeholder: string; autofocus: boolean; editorClass: string; ariaLabel: string; editorProps: Record<string, any>; extensions: any[]; starterKit: Record<string, any>; nodeSpecs: any[]; uploadImage: ((...args: any[]) => any) | null; maxLength: (number) | null; enforceMaxLength: boolean; bubbleMenuShouldShow: ((...args: any[]) => any) | null } = {
    ..._props,
    editable: _props.editable ?? true,
    placeholder: _props.placeholder ?? '',
    autofocus: _props.autofocus ?? false,
    editorClass: _props.editorClass ?? '',
    ariaLabel: _props.ariaLabel ?? 'Rich text editor',
    editorProps: _props.editorProps ?? __defaultEditorProps,
    extensions: _props.extensions ?? __defaultExtensions,
    starterKit: _props.starterKit ?? __defaultStarterKit,
    nodeSpecs: _props.nodeSpecs ?? __defaultNodeSpecs,
    uploadImage: _props.uploadImage ?? null,
    maxLength: _props.maxLength ?? null,
    enforceMaxLength: _props.enforceMaxLength ?? false,
    bubbleMenuShouldShow: _props.bubbleMenuShouldShow ?? null,
  };
  const _renderToolbarRef = useRef(props.renderToolbar);
  _renderToolbarRef.current = props.renderToolbar;
  const _renderBubbleMenuRef = useRef(props.renderBubbleMenu);
  _renderBubbleMenuRef.current = props.renderBubbleMenu;
  const _renderFloatingMenuRef = useRef(props.renderFloatingMenu);
  _renderFloatingMenuRef.current = props.renderFloatingMenu;
  const _renderLinkEditorRef = useRef(props.renderLinkEditor);
  _renderLinkEditorRef.current = props.renderLinkEditor;
  const _renderNodeViewRef = useRef(props.renderNodeView);
  _renderNodeViewRef.current = props.renderNodeView;
  const lastHtml = useRef<any>(null);
  const bubbleMenuEl = useRef<any>(null);
  const floatingMenuEl = useRef<any>(null);
  const linkEditorEl = useRef<any>(null);
  const editor = useRef<any>(null);
  const openFlag = useRef(false);
  const toolbarDispose = useRef<any>(null);
  const bubbleMenuDispose = useRef<any>(null);
  const floatingMenuDispose = useRef<any>(null);
  const linkEditorHandle = useRef<any>(null);
  const linkInputEl = useRef<any>(null);
  const lastLinkKey = useRef<any>(null);
  const [html, setHtml] = useControllableState({
    value: props.html,
    defaultValue: props.defaultHtml ?? '<p>Start writing…</p>',
    onValueChange: props.onHtmlChange,
  });
  const _ariaLabelRef = useRef(props.ariaLabel);
  _ariaLabelRef.current = props.ariaLabel;
  const _autofocusRef = useRef(props.autofocus);
  _autofocusRef.current = props.autofocus;
  const _bubbleMenuShouldShowRef = useRef(props.bubbleMenuShouldShow);
  _bubbleMenuShouldShowRef.current = props.bubbleMenuShouldShow;
  const _editableRef = useRef(props.editable);
  _editableRef.current = props.editable;
  const _editorClassRef = useRef(props.editorClass);
  _editorClassRef.current = props.editorClass;
  const _editorPropsRef = useRef(props.editorProps);
  _editorPropsRef.current = props.editorProps;
  const _enforceMaxLengthRef = useRef(props.enforceMaxLength);
  _enforceMaxLengthRef.current = props.enforceMaxLength;
  const _extensionsRef = useRef(props.extensions);
  _extensionsRef.current = props.extensions;
  const _maxLengthRef = useRef(props.maxLength);
  _maxLengthRef.current = props.maxLength;
  const _nodeSpecsRef = useRef(props.nodeSpecs);
  _nodeSpecsRef.current = props.nodeSpecs;
  const _onBlurRef = useRef(props.onBlur);
  _onBlurRef.current = props.onBlur;
  const _onFocusRef = useRef(props.onFocus);
  _onFocusRef.current = props.onFocus;
  const _onSelectionUpdateRef = useRef(props.onSelectionUpdate);
  _onSelectionUpdateRef.current = props.onSelectionUpdate;
  const _onUpdateRef = useRef(props.onUpdate);
  _onUpdateRef.current = props.onUpdate;
  const _placeholderRef = useRef(props.placeholder);
  _placeholderRef.current = props.placeholder;
  const _starterKitRef = useRef(props.starterKit);
  _starterKitRef.current = props.starterKit;
  const _uploadImageRef = useRef(props.uploadImage);
  _uploadImageRef.current = props.uploadImage;
  const _htmlRef = useRef(html);
  _htmlRef.current = html;
  const [active, setActive] = useState({
    bold: false,
    italic: false,
    h1: false,
    h2: false,
    bulletList: false,
    underline: false,
    orderedList: false,
    link: false
  });
  const [count, setCount] = useState({
    characters: 0,
    words: 0
  });
  const [linkState, setLinkState] = useState({
    href: '',
    attrs: {}
  });
  const toolbarEl = useRef<HTMLDivElement | null>(null);
  const editorEl = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);
  const _watch1First = useRef(true);

  const refreshActive = useCallback(() => {
    if (!editor.current) return;
    setActive({
      bold: editor.current.isActive('bold'),
      italic: editor.current.isActive('italic'),
      h1: editor.current.isActive('heading', {
        level: 1
      }),
      h2: editor.current.isActive('heading', {
        level: 2
      }),
      bulletList: editor.current.isActive('bulletList'),
      underline: editor.current.isActive('underline'),
      orderedList: editor.current.isActive('orderedList'),
      link: editor.current.isActive('link')
    });
  }, []);
  function applyLink(attrs: any) {
    // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
    // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
    if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
    editor.current?.chain().focus().extendMarkRange('link').setLink(attrs).run();
    openFlag.current = false;
  }
  function removeLink() {
    editor.current?.chain().focus().extendMarkRange('link').unsetLink().run();
    openFlag.current = false;
  }
  function forceMenuRecheck() {
    if (!editor.current) return;
    const visible = editor.current.isEditable && (editor.current.isActive('link') || openFlag.current);
    editor.current.view.dispatch(editor.current.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
  }
  function closeLink() {
    openFlag.current = false;
    // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
    // current link href. The surface itself is link-anchored (like Google Docs) — it
    // stays while the caret is on a link and hides once openFlag is clear and the
    // caret is off any link (or the doc is not editable).
    if (linkInputEl.current) linkInputEl.current.value = linkState.href;
    editor.current?.commands.focus();
    forceMenuRecheck();
  }
  const buildLinkScope = useCallback((href: any, attrs: any) => ({
    editor: editor.current,
    href,
    attrs,
    setLink: applyLink,
    unsetLink: removeLink,
    close: closeLink
  }), [applyLink, closeLink, removeLink]);
  const refreshLink = useCallback(() => {
    if (!editor.current) return;
    const a = editor.current.getAttributes('link');
    const href = a.href || '';
    // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
    // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
    // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
    // change the link).
    const key = href + '' + JSON.stringify(a);
    if (key === lastLinkKey.current) return;
    lastLinkKey.current = key;
    setLinkState({
      href,
      attrs: a
    });
    if (linkEditorHandle.current) {
      linkEditorHandle.current.update(buildLinkScope(href, a));
    } else if (linkInputEl.current && !linkInputEl.current.matches(':focus')) {
      // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
      // the user typing in this input?" guard holds inside a shadow root — on the Lit
      // target document.activeElement is the shadow HOST, so a document.activeElement
      // check would always miss and stomp the user's in-progress URL. `:focus` is
      // per-element and shadow-boundary-agnostic.
      linkInputEl.current.value = href;
    }
  }, [buildLinkScope]);
  const openLinkEditor = useCallback(() => {
    openFlag.current = true;
    editor.current?.commands.focus();
    refreshLink();
    forceMenuRecheck();
  }, [forceMenuRecheck, refreshLink]);
  const buildDefaultLinkEditor = useCallback((el: any) => {
    const input = document.createElement('input');
    input.type = 'text';
    input.className = 'rozie-tiptap-link-input';
    input.placeholder = 'https://…';
    const apply = document.createElement('button');
    apply.type = 'button';
    apply.className = 'rozie-tiptap-link-apply';
    apply.textContent = 'Apply';
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'rozie-tiptap-link-remove';
    remove.textContent = 'Remove';
    const cancel = document.createElement('button');
    cancel.type = 'button';
    cancel.className = 'rozie-tiptap-link-cancel';
    cancel.textContent = 'Cancel';
    // Keep the caret/selection in the document when a control is pressed (a plain
    // click would blur the editor and collapse the selection before the command runs).
    const keepFocus = (e: any) => e.preventDefault();
    for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
    apply.addEventListener('click', () => applyLink({
      href: input.value
    }));
    remove.addEventListener('click', removeLink);
    cancel.addEventListener('click', closeLink);
    input.addEventListener('keydown', (e: any) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        applyLink({
          href: input.value
        });
      } else if (e.key === 'Escape') {
        e.preventDefault();
        closeLink();
      }
    });
    el.appendChild(input);
    el.appendChild(apply);
    el.appendChild(remove);
    el.appendChild(cancel);
    linkInputEl.current = input;
  }, [applyLink, closeLink, removeLink]);
  const refreshCount = useCallback(() => {
    if (!editor.current) return;
    const storage = editor.current.storage.characterCount;
    setCount({
      characters: storage ? storage.characters() : editor.current.getText().length,
      words: storage ? storage.words() : editor.current.getText().split(/\s+/).filter(Boolean).length
    });
  }, []);
  // ── StarterKit collision-aware config (ask A). StarterKit bundles several
  // node/mark extensions INTERNALLY (invisible to a top-level array dedup) —
  // e.g. its own `Link`. A consumer supplying a custom same-named extension via
  // `extensions` therefore collides with StarterKit's copy and TipTap warns
  // "Duplicate extension names found" while keeping BOTH; only
  // `StarterKit.configure({ link:false })` actually disables StarterKit's. This
  // map + helper make "consumer wins" true by auto-disabling the StarterKit key
  // whenever the consumer supplies a same-named extension AND has not already
  // decided that key's fate via the `starterKit` prop. Identity for the 15
  // node/mark keys StarterKit exposes as `Partial<Options> | false`, plus the
  // undo/redo option key `undoRedo` — mapped from BOTH its actual installed
  // `.name` (`'undoRedo'`, verified against `@tiptap/extensions@3.23.5`) and the
  // TipTap v2 alias `'history'` as a safety net for a consumer porting a v2
  // History extension. Structural/plumbing StarterKit keys (document, text,
  // dropcursor, gapcursor, listKeymap, trailingNode) are NOT node/mark
  // replacements and are intentionally excluded.
  const STARTERKIT_COLLISION_MAP = {
    bold: 'bold',
    italic: 'italic',
    strike: 'strike',
    code: 'code',
    heading: 'heading',
    paragraph: 'paragraph',
    blockquote: 'blockquote',
    codeBlock: 'codeBlock',
    hardBreak: 'hardBreak',
    horizontalRule: 'horizontalRule',
    bulletList: 'bulletList',
    orderedList: 'orderedList',
    listItem: 'listItem',
    link: 'link',
    underline: 'underline',
    undoRedo: 'undoRedo',
    history: 'undoRedo'
  };

  // Pure helper — returns `userConfig` extended so any StarterKit-bundled
  // node/mark the consumer replaced (a same-named entry in `exts`) is disabled
  // UNLESS the consumer already decided that key's fate in `userConfig` (an `in`
  // presence check, so an explicit `false` OR an explicit options object both
  // count as "consumer decided" — D-02, consumer wins unless configured
  // explicitly). Never invokes consumer code — only reads `.name` and does key
  // presence checks (guards a non-object/missing `.name` entry by skipping it).
  const buildStarterKitConfig = useCallback((userConfig: any, exts: any) => {
    const effective = {
      ...userConfig
    };
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      if (typeof name !== 'string') continue;
      const optionKey = STARTERKIT_COLLISION_MAP[name];
      if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
    }
    return effective;
  }, []);
  const dedupeExtensionsByName = useCallback((exts: any) => {
    const byKey = new Map();
    let anonSeq = 0;
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
      byKey.set(key, ext);
    }
    return [...byKey.values()];
  }, []);
  function makeNodeView(nv: any, spec: any) {
    return (props: any) => {
      const {
        node,
        getPos,
        editor: ed
      } = props;
      // hasContentDOM derives from the spec, not a bare boolean: an editable node
      // is one that is NOT an atom and declares `content` (e.g. 'inline*').
      const hasContentDOM = !spec.atom && !!spec.content;
      // engine-owned outer host the consumer fragment mounts into.
      const dom = document.createElement(hasContentDOM ? 'div' : 'span');
      dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
      // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
      // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
      const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
      if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
      const updateAttributes = (attrs: any) => {
        if (typeof getPos !== 'function') return;
        const pos = getPos();
        if (pos == null) return;
        ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
          ...node.attrs,
          ...attrs
        }));
      };
      const buildScope = (n: any, selected: any) => ({
        node: n,
        selected,
        updateAttributes,
        getPos,
        editor: ed,
        ...(contentDOM ? {
          contentDOM
        } : {})
      });

      // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
      // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
      const handle = nv(dom, buildScope(node, false));

      // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
      // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
      // ProseMirror manages `contentDOM` and renders the node's editable children
      // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
      // rendered into `dom` by the per-target reactive portal — synchronously on
      // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
      // Angular (REQ-23). A query-after-render graft (retried across a microtask +
      // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
      // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
      // and the framework never reconciles it away (the hole carries no child binding).
      const graftContentDOM = (attempt: any) => {
        if (!contentDOM) return;
        const hole = dom.querySelector('[data-rozie-hole]');
        if (hole) {
          if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
          return;
        }
        if (attempt < 5) {
          if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
        }
      };
      graftContentDOM(0);

      // After a reactive re-render (chrome update), re-graft so a fragment that
      // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
      // (REQ-24 — the editable subtree survives every chrome update).
      const updateInPlace = (n: any, selected: any) => {
        handle.update(buildScope(n, selected));
        if (contentDOM) graftContentDOM(0);
      };
      return {
        dom,
        ...(contentDOM ? {
          contentDOM
        } : {}),
        // attr / content change for THIS node → re-render the fragment in place,
        // keep the view (return true). The new node identity is forwarded so the
        // fragment reads fresh node.attrs (REQ-26).
        update(nextNode: any) {
          if (nextNode.type !== node.type) return false;
          updateInPlace(nextNode, false);
          return true;
        },
        // NodeSelection enters/leaves the node → toggle `selected` in scope so the
        // chip's selected styling is pure engine-driven reactive `update`.
        selectNode() {
          updateInPlace(node, true);
        },
        deselectNode() {
          updateInPlace(node, false);
        },
        destroy() {
          handle.dispose();
        }
      };
    };
  }
  function parseTagSelector(selector: any) {
    const raw = typeof selector === 'string' ? selector : '';
    const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
    const el = elMatch ? elMatch[0] : raw || 'span';
    const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
    if (!attrMatch) return {
      el,
      attr: null,
      value: ''
    };
    const attr = (attrMatch[1] ?? '').trim();
    const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
    return {
      el,
      attr,
      value
    };
  }
  const makeNodeViewExtensions = useCallback((nv: any, specs: any) => specs.map((spec: any) => {
    // hasContentDOM decides the renderHTML hole: an editable (non-atom,
    // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
    // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
    // a leaf node spec" otherwise).
    const hasContentDOM = !spec.atom && !!spec.content;
    const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
    const {
      el,
      attr,
      value
    } = parseTagSelector(firstTag);
    return Node.create({
      name: spec.name,
      group: spec.group ?? 'block',
      inline: spec.inline ?? false,
      atom: spec.atom ?? false,
      selectable: spec.selectable ?? true,
      defining: spec.defining ?? false,
      ...(spec.content ? {
        content: spec.content
      } : {}),
      addAttributes: () => spec.attrs ?? {},
      parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
        tag: t
      })),
      renderHTML: ({
        HTMLAttributes
      }: any) => hasContentDOM ? [el, {
        ...(attr ? {
          [attr]: value
        } : {}),
        ...HTMLAttributes
      }, 0] : [el, {
        ...(attr ? {
          [attr]: value
        } : {}),
        ...HTMLAttributes
      }],
      addNodeView: () => makeNodeView(nv, spec)
    });
  }), [makeNodeView, parseTagSelector]);
  function findImageFile(files: any) {
    if (!files) return undefined;
    for (let i = 0; i < files.length; i++) {
      const f = files[i];
      if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
    }
    return undefined;
  }
  // uploadImage paste/drop fallbacks (ask D / D-04) — ProseMirror `editorProps`
  // handlers. TOP-LEVEL functions (siblings of `refreshActive`/the $expose
  // verbs below), NOT nested inside $onMount's ternary/object-literal — a
  // closure reading the component-scope `editor` from several function-levels
  // deep inside $onMount (object-literal method → `.then` callback) hits a
  // `this`-rebinding gap on the class-based targets (Angular/Lit) that the
  // emitter's nested-`this` repair does not reach at that depth
  // (emitter-backlog). A top-level function is only ONE level removed from the
  // promoted-`this` boundary — the same shallow depth as the `onUpdate` /
  // `$watch` callbacks elsewhere in this file, which already compile clean —
  // so referencing `editor` here needs no repair at all. Each handler claims
  // ONLY an image/* payload: returns `true` SYNCHRONOUSLY (claiming the
  // paste/drop now — never awaits inside the handler) and inserts the resolved
  // URL once the consumer's uploadImage promise settles; a rejection is
  // swallowed (`.catch(() => {})`) so a failed upload never crashes the editor
  // (T-e7i-01). Returns `false` for a non-image payload — or, for drop, an
  // internal node move — so ProseMirror (or a consumer editorProps handler,
  // which still wins via the LAST spread) processes it normally.
  function handlePaste(view: any, event: any, slice: any) {
    // Captured into a local (not repeated `$props.uploadImage` member reads) so
    // the null-check narrows the type on every target — including Lit, where
    // the Function prop lowers to a nullable function type and a bare
    // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
    // typecheck (TS2721) even though this handler is only ever wired into
    // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
    // gate already guarantees this in practice).
    const upload = props.uploadImage;
    if (!upload) return false;
    const file = findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
    if (!file) return false;
    event.preventDefault();
    upload(file).then((url: any) => {
      editor.current?.chain().focus().setImage({
        src: url
      }).run();
    }).catch(() => {});
    return true;
  }
  function handleDrop(view: any, event: any, slice: any, moved: any) {
    if (moved) return false;
    // See handlePaste — local capture for the same cross-target null-narrowing.
    const upload = props.uploadImage;
    if (!upload) return false;
    const file = findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
    if (!file) return false;
    event.preventDefault();
    const pos = view.posAtCoords({
      left: event.clientX,
      top: event.clientY
    });
    upload(file).then((url: any) => {
      const insertPos = pos ? pos.pos : editor.current ? editor.current.state.selection.head : 0;
      editor.current?.chain().focus().insertContentAt(insertPos, {
        type: 'image',
        attrs: {
          src: url
        }
      }).run();
    }).catch(() => {});
    return true;
  }
  // ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
  // the marquee surface: 25 verbs over the live Editor, uniform across all 6
  // targets. Each guards the pre-mount / destroyed `editor = null`.
  //
  // Collision discipline:
  //   - The content setter is named `setContent`, NOT `setHtml` — an `html` model
  //     prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
  //     $expose verb would collide on the React target (ROZ524). (CodeMirror's
  //     setValue→replaceValue lesson, html edition.)
  //   - None of the 25 names collide with LitElement reserved lifecycle methods
  //     (update/render/firstUpdated/updated/willUpdate/requestUpdate).
  //   - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
  //     `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
  //     class-based targets (Angular) an output field and a method cannot share a
  //     name (ROZ121). The diagnostic's own guidance: rename the method, keep the
  //     event's public name. (The expose-verb-vs-event-name collision lesson.)
  //   - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
  //     ariaLabel/editorProps/extensions).
  function getEditor() {
    return editor.current;
  }
  function focusEditor() {
    editor.current?.commands.focus();
  }
  function blurEditor() {
    editor.current?.commands.blur();
  }
  function getHTML() {
    return editor.current ? editor.current.getHTML() : '';
  }
  function getJSON() {
    return editor.current ? editor.current.getJSON() : null;
  }
  // Plain-text extraction — word/char counts, search indexing, plaintext export.
  // Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
  // alongside getHTML/getJSON but never wired; now first-class.
  // Plain-text extraction — word/char counts, search indexing, plaintext export.
  // Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
  // alongside getHTML/getJSON but never wired; now first-class.
  function getText() {
    return editor.current ? editor.current.getText() : '';
  }
  // setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
  // update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
  // reflect into the model so a programmatic set keeps the bound state in sync.
  // setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
  // update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
  // reflect into the model so a programmatic set keeps the bound state in sync.
  function setContent(next: any) {
    if (!editor.current) return;
    const v = next ?? '';
    if (v === lastHtml.current) return;
    lastHtml.current = v;
    editor.current.commands.setContent(v, {
      emitUpdate: false
    });
    setHtml(v);
    refreshActive();
    refreshCount();
    refreshLink();
  }
  function clearContent() {
    if (!editor.current) return;
    editor.current.commands.clearContent();
    lastHtml.current = editor.current.getHTML();
    setHtml(lastHtml.current);
    refreshActive();
    refreshCount();
    refreshLink();
  }
  function toggleBold() {
    editor.current?.chain().focus().toggleBold().run();
    refreshActive();
  }
  function toggleItalic() {
    editor.current?.chain().focus().toggleItalic().run();
    refreshActive();
  }
  function toggleHeading(level: any) {
    editor.current?.chain().focus().toggleHeading({
      level: level ?? 1
    }).run();
    refreshActive();
  }
  function toggleBulletList() {
    editor.current?.chain().focus().toggleBulletList().run();
    refreshActive();
  }
  function toggleUnderline() {
    editor.current?.chain().focus().toggleUnderline().run();
    refreshActive();
  }
  function toggleOrderedList() {
    editor.current?.chain().focus().toggleOrderedList().run();
    refreshActive();
  }
  function undo() {
    editor.current?.chain().focus().undo().run();
    refreshActive();
  }
  function redo() {
    editor.current?.chain().focus().redo().run();
    refreshActive();
  }
  // Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
  // chain().focus().toggleBold().setColor('#f00').run()). null before mount.
  // Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
  // chain().focus().toggleBold().setColor('#f00').run()). null before mount.
  function chain() {
    return editor.current ? editor.current.chain().focus() : null;
  }
  // Read-side toolbar primitives. These are precisely what a bring-your-own
  // toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
  // the component already computes internally via refreshActive() — exposing them
  // removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
  //   - isActive(name, attrs?): is a mark/node active in the current selection
  //     (drive toolbar button active styling). False before mount.
  //   - can(): the command-availability chain (editor.can().chain()…run()) for
  //     enable/disable of toolbar buttons. null before mount (mirrors chain()).
  //   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
  // Read-side toolbar primitives. These are precisely what a bring-your-own
  // toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
  // the component already computes internally via refreshActive() — exposing them
  // removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
  //   - isActive(name, attrs?): is a mark/node active in the current selection
  //     (drive toolbar button active styling). False before mount.
  //   - can(): the command-availability chain (editor.can().chain()…run()) for
  //     enable/disable of toolbar buttons. null before mount (mirrors chain()).
  //   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
  function isActive(name: any, attrs: any) {
    return editor.current ? editor.current.isActive(name, attrs) : false;
  }
  function can() {
    return editor.current ? editor.current.can() : null;
  }
  function isEmpty() {
    return editor.current ? editor.current.isEmpty : true;
  }
  // Character/word count reads (D-04). Prefer the CharacterCount extension's live
  // storage when registered (maxLength set or #count slot filled); otherwise a
  // text-based fallback so these ALWAYS return a number — 0 before mount, and a
  // correct count even on a stock <TipTap> that never registered CharacterCount.
  // Character/word count reads (D-04). Prefer the CharacterCount extension's live
  // storage when registered (maxLength set or #count slot filled); otherwise a
  // text-based fallback so these ALWAYS return a number — 0 before mount, and a
  // correct count even on a stock <TipTap> that never registered CharacterCount.
  function getCharacterCount() {
    if (!editor.current) return 0;
    return editor.current.storage.characterCount ? editor.current.storage.characterCount.characters() : editor.current.getText().length;
  }
  function getWordCount() {
    if (!editor.current) return 0;
    return editor.current.storage.characterCount ? editor.current.storage.characterCount.words() : editor.current.getText().split(/\s+/).filter(Boolean).length;
  }
  // setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
  // applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
  // (buildLinkScope above), so the imperative handle and the slot-scope verb
  // implementation cannot disagree. Four-way collision check:
  //   - not a prop name — the 14 props are html / editable / placeholder /
  //     autofocus / editorClass / ariaLabel / editorProps / extensions /
  //     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
  //     bubbleMenuShouldShow;
  //   - not an emitted event name — the 4 events are update / selectionUpdate /
  //     focus / blur (the ROZ121 Angular output-field-vs-method rule);
  //   - not an existing $expose verb — the 23 names already in the object below;
  //   - not a React auto-generated model setter — the only model prop is
  //     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
  //     `setContent`), and not a LitElement lifecycle method (update / render /
  //     firstUpdated / updated / willUpdate / requestUpdate).
  // applyLink already ignores an attrs object without a non-empty string href
  // (no degenerate empty-href anchor is ever written), and both verbs no-op
  // before mount / after destroy through the `editor?.` guards already inside
  // applyLink/removeLink — no second validation path is introduced.
  // setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
  // applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
  // (buildLinkScope above), so the imperative handle and the slot-scope verb
  // implementation cannot disagree. Four-way collision check:
  //   - not a prop name — the 14 props are html / editable / placeholder /
  //     autofocus / editorClass / ariaLabel / editorProps / extensions /
  //     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
  //     bubbleMenuShouldShow;
  //   - not an emitted event name — the 4 events are update / selectionUpdate /
  //     focus / blur (the ROZ121 Angular output-field-vs-method rule);
  //   - not an existing $expose verb — the 23 names already in the object below;
  //   - not a React auto-generated model setter — the only model prop is
  //     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
  //     `setContent`), and not a LitElement lifecycle method (update / render /
  //     firstUpdated / updated / willUpdate / requestUpdate).
  // applyLink already ignores an attrs object without a non-empty string href
  // (no degenerate empty-href anchor is ever written), and both verbs no-op
  // before mount / after destroy through the `editor?.` guards already inside
  // applyLink/removeLink — no second validation path is introduced.
  function setLink(attrs: any) {
    applyLink(attrs);
  }
  function unsetLink() {
    removeLink();
  }

  const _buildDefaultLinkEditorRef = useRef(buildDefaultLinkEditor);
  _buildDefaultLinkEditorRef.current = buildDefaultLinkEditor;
  const _buildLinkScopeRef = useRef(buildLinkScope);
  _buildLinkScopeRef.current = buildLinkScope;
  const _handleDropRef = useRef(handleDrop);
  _handleDropRef.current = handleDrop;
  const _handlePasteRef = useRef(handlePaste);
  _handlePasteRef.current = handlePaste;
  const _makeNodeViewExtensionsRef = useRef(makeNodeViewExtensions);
  _makeNodeViewExtensionsRef.current = makeNodeViewExtensions;
  const _refreshLinkRef = useRef(refreshLink);
  _refreshLinkRef.current = refreshLink;
  useEffect(() => {
    const _handleDropStable: typeof _handleDropRef.current = (...args) => _handleDropRef.current(...args);
    const _handlePasteStable: typeof _handlePasteRef.current = (...args) => _handlePasteRef.current(...args);
    interface ReactivePortalHandle {
    update(scope: unknown): void;
    dispose(): void;
  }
  const portals = {
    toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _renderToolbarRef.current ?? props.slots?.['toolbar'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal toolbar { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _renderBubbleMenuRef.current ?? props.slots?.['bubbleMenu'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal bubbleMenu { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _renderFloatingMenuRef.current ?? props.slots?.['floatingMenu'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal floatingMenu { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
    linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
      const slot = _renderLinkEditorRef.current ?? props.slots?.['linkEditor'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal linkEditor { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
      const root = createRoot(container);
      const renderScope = (s: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): void => {
        flushSync(() => root.render(slot(s)));
      };
      renderScope(scope);
      portalRoots.current.add(root);
      return {
        update: (s: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): void => renderScope(s),
        dispose: (): void => {
          root.unmount();
          portalRoots.current.delete(root);
        },
      };
    },
    nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
      const slot = _renderNodeViewRef.current ?? props.slots?.['nodeView'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal nodeView { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
      const root = createRoot(container);
      const renderScope = (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => {
        flushSync(() => root.render(slot(s)));
      };
      renderScope(scope);
      portalRoots.current.add(root);
      return {
        update: (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => renderScope(s),
        dispose: (): void => {
          root.unmount();
          portalRoots.current.delete(root);
        },
      };
    },
  };
    lastHtml.current = _htmlRef.current;

    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    const nodeViewExtensions = (props.renderNodeView ?? props.slots?.["nodeView"]) && _nodeSpecsRef.current.length ? _makeNodeViewExtensionsRef.current(portals.nodeView, _nodeSpecsRef.current) : [];

    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    const placeholderExtensions = _placeholderRef.current ? [Placeholder.configure({
      placeholder: _placeholderRef.current
    })] : [];

    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    if ((props.renderBubbleMenu ?? props.slots?.["bubbleMenu"])) {
      bubbleMenuEl.current = document.createElement('div');
      bubbleMenuEl.current.className = 'rozie-tiptap-bubble-menu';
    }
    if ((props.renderFloatingMenu ?? props.slots?.["floatingMenu"])) {
      floatingMenuEl.current = document.createElement('div');
      floatingMenuEl.current.className = 'rozie-tiptap-floating-menu';
    }
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    linkEditorEl.current = document.createElement('div');
    linkEditorEl.current.className = 'rozie-tiptap-link-editor';
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    const menuExtensions = [...(bubbleMenuEl.current ? [BubbleMenu.configure({
      pluginKey: 'rozieBubbleMenu',
      element: bubbleMenuEl.current,
      ...(_bubbleMenuShouldShowRef.current ? {
        shouldShow: _bubbleMenuShouldShowRef.current
      } : {})
    })] : []), ...(floatingMenuEl.current ? [FloatingMenu.configure({
      element: floatingMenuEl.current
    })] : []), ...(linkEditorEl.current ? [BubbleMenu.configure({
      pluginKey: 'rozieLinkEditor',
      element: linkEditorEl.current,
      // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
      // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
      // button set openFlag (create) — never on a bare selection.
      shouldShow: ({
        editor
      }: any) => editor.isEditable && (editor.isActive('link') || openFlag.current)
    })] : [])];

    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    const imageExtensions = _uploadImageRef.current ? [Image] : [];

    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    const needsCount = _maxLengthRef.current != null || (props.renderCount ?? props.slots?.["count"]);
    const characterCountExtensions = needsCount ? [CharacterCount.configure(_enforceMaxLengthRef.current && _maxLengthRef.current != null ? {
      limit: _maxLengthRef.current
    } : {})] : [];

    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    const uploadHandlers = _uploadImageRef.current ? {
      handlePaste: _handlePasteStable,
      handleDrop: _handleDropStable
    } : {};
    editor.current = new Editor({
      element: editorEl.current!,
      content: _htmlRef.current,
      editable: _editableRef.current,
      autofocus: _autofocusRef.current,
      // StarterKit first (config-disabled per the collision scan below); the
      // Placeholder ext next; the reactive node-view nodes next; consumer
      // extensions LAST so they win (TipTap applies later-registered extensions
      // over earlier ones for the same node/mark) — and the whole array is
      // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
      // of the config-level auto-disable (D-02), which is what actually silences
      // StarterKit's internal same-named extension (e.g. its bundled `Link`).
      extensions: dedupeExtensionsByName([StarterKit.configure(buildStarterKitConfig(_starterKitRef.current, _extensionsRef.current)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ..._extensionsRef.current]),
      editorProps: {
        attributes: {
          'aria-label': _ariaLabelRef.current,
          ...(_editorClassRef.current ? {
            class: _editorClassRef.current
          } : {}),
          ...(_placeholderRef.current ? {
            'data-placeholder': _placeholderRef.current,
            'aria-placeholder': _placeholderRef.current
          } : {})
        },
        // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
        // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
        // `{}` (no-op) when $props.uploadImage is unset.
        ...uploadHandlers,
        // Consumer editorProps spread LAST — full ProseMirror editorProps control
        // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
        ..._editorPropsRef.current
      },
      onUpdate: ({
        editor
      }: any) => {
        const next = editor.getHTML();
        lastHtml.current = next;
        // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
        if (next !== _htmlRef.current) setHtml(next);
        refreshCount();
        _refreshLinkRef.current();
        _onUpdateRef.current && _onUpdateRef.current(next);
      },
      onSelectionUpdate: () => {
        refreshActive();
        _refreshLinkRef.current();
        _onSelectionUpdateRef.current && _onSelectionUpdateRef.current();
      },
      onFocus: () => _onFocusRef.current && _onFocusRef.current(),
      onBlur: ({
        event
      }: any) => {
        // Clear the create-mode latch when focus truly leaves the editor + its link
        // surface — but NOT when it moves INTO the link editor host (clicking the URL
        // input blurs the editor; the buttons are already covered by their keepFocus
        // mousedown). Without this, openFlag stays true after the user dismisses the
        // create affordance by clicking away, so the editor spuriously re-surfaces on
        // the next unrelated selection.
        const to = event && event.relatedTarget;
        if (!(to instanceof Node && linkEditorEl.current && linkEditorEl.current.contains(to))) openFlag.current = false;
        _onBlurRef.current && _onBlurRef.current();
      }
    });
    refreshActive();
    refreshCount();
    _refreshLinkRef.current();

    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    if ((props.renderToolbar ?? props.slots?.["toolbar"]) && toolbarEl.current) {
      toolbarDispose.current = portals.toolbar(toolbarEl.current!, {
        editor: editor.current
      });
    }

    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    if (bubbleMenuEl.current) {
      bubbleMenuDispose.current = portals.bubbleMenu(bubbleMenuEl.current, {
        editor: editor.current
      });
    }
    if (floatingMenuEl.current) {
      floatingMenuDispose.current = portals.floatingMenu(floatingMenuEl.current, {
        editor: editor.current
      });
    }

    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    if (linkEditorEl.current) {
      if ((props.renderLinkEditor ?? props.slots?.["linkEditor"])) {
        // Read the initial link attrs straight off the live editor (NOT
        // `$data.linkState`, written by the refreshLink() call above in this
        // same tick) — the same React stale-read avoidance as buildLinkScope's
        // other call site.
        const initialLinkAttrs = editor.current.getAttributes('link');
        linkEditorHandle.current = portals.linkEditor(linkEditorEl.current, _buildLinkScopeRef.current(initialLinkAttrs.href || '', initialLinkAttrs));
      } else {
        _buildDefaultLinkEditorRef.current(linkEditorEl.current);
        // Prefill correction (D-04): the refreshLink() call above (right after
        // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
        // exist yet at that point, so every LATER refreshLink() for the same
        // link early-returns, leaving the just-created input empty even when the
        // caret starts inside a link. Seed it directly from the LIVE editor
        // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
        // $data key immediately after refreshLink() just wrote it hits the
        // React setState-is-async stale-read trap (the same write-then-read-in-
        // one-handler class ROZ138 warns about elsewhere in this file), since
        // $data.linkState was written by the refreshLink() call directly above.
        // `editor` is a plain instance handle, not reactive state, so reading it
        // straight off the engine is synchronous and target-uniform. A no-link
        // mount leaves this the empty string (unchanged).
        if (linkInputEl.current) linkInputEl.current.value = editor.current.getAttributes('link').href || '';
      }
    }
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      toolbarDispose.current?.();
      toolbarDispose.current = null;
      bubbleMenuDispose.current?.();
      bubbleMenuDispose.current = null;
      floatingMenuDispose.current?.();
      floatingMenuDispose.current = null;
      linkEditorHandle.current?.dispose();
      linkEditorHandle.current = null;
      linkEditorEl.current = null;
      linkInputEl.current = null;
      editor.current?.destroy();
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    const v = html;
    if (!editor.current) return;
    if (v === lastHtml.current) return;
    lastHtml.current = v;
    editor.current.commands.setContent(v, {
      emitUpdate: false
    });
    refreshActive();
    refreshCount();
    refreshLink();
  }, [html]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    const v = props.editable;
    editor.current?.setEditable(v, false);
  }, [props.editable]);

  const _rozieExposeRef = useRef({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList, undo, redo, chain, isActive, can, isEmpty, getCharacterCount, getWordCount, openLinkEditor, setLink, unsetLink });
  _rozieExposeRef.current = { getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList, undo, redo, chain, isActive, can, isEmpty, getCharacterCount, getWordCount, openLinkEditor, setLink, unsetLink };
  useImperativeHandle(ref, () => ({ getEditor: (...args: Parameters<typeof getEditor>): ReturnType<typeof getEditor> => _rozieExposeRef.current.getEditor(...args), focusEditor: (...args: Parameters<typeof focusEditor>): ReturnType<typeof focusEditor> => _rozieExposeRef.current.focusEditor(...args), blurEditor: (...args: Parameters<typeof blurEditor>): ReturnType<typeof blurEditor> => _rozieExposeRef.current.blurEditor(...args), getHTML: (...args: Parameters<typeof getHTML>): ReturnType<typeof getHTML> => _rozieExposeRef.current.getHTML(...args), getJSON: (...args: Parameters<typeof getJSON>): ReturnType<typeof getJSON> => _rozieExposeRef.current.getJSON(...args), getText: (...args: Parameters<typeof getText>): ReturnType<typeof getText> => _rozieExposeRef.current.getText(...args), setContent: (...args: Parameters<typeof setContent>): ReturnType<typeof setContent> => _rozieExposeRef.current.setContent(...args), clearContent: (...args: Parameters<typeof clearContent>): ReturnType<typeof clearContent> => _rozieExposeRef.current.clearContent(...args), toggleBold: (...args: Parameters<typeof toggleBold>): ReturnType<typeof toggleBold> => _rozieExposeRef.current.toggleBold(...args), toggleItalic: (...args: Parameters<typeof toggleItalic>): ReturnType<typeof toggleItalic> => _rozieExposeRef.current.toggleItalic(...args), toggleHeading: (...args: Parameters<typeof toggleHeading>): ReturnType<typeof toggleHeading> => _rozieExposeRef.current.toggleHeading(...args), toggleBulletList: (...args: Parameters<typeof toggleBulletList>): ReturnType<typeof toggleBulletList> => _rozieExposeRef.current.toggleBulletList(...args), toggleUnderline: (...args: Parameters<typeof toggleUnderline>): ReturnType<typeof toggleUnderline> => _rozieExposeRef.current.toggleUnderline(...args), toggleOrderedList: (...args: Parameters<typeof toggleOrderedList>): ReturnType<typeof toggleOrderedList> => _rozieExposeRef.current.toggleOrderedList(...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), chain: (...args: Parameters<typeof chain>): ReturnType<typeof chain> => _rozieExposeRef.current.chain(...args), isActive: (...args: Parameters<typeof isActive>): ReturnType<typeof isActive> => _rozieExposeRef.current.isActive(...args), can: (...args: Parameters<typeof can>): ReturnType<typeof can> => _rozieExposeRef.current.can(...args), isEmpty: (...args: Parameters<typeof isEmpty>): ReturnType<typeof isEmpty> => _rozieExposeRef.current.isEmpty(...args), getCharacterCount: (...args: Parameters<typeof getCharacterCount>): ReturnType<typeof getCharacterCount> => _rozieExposeRef.current.getCharacterCount(...args), getWordCount: (...args: Parameters<typeof getWordCount>): ReturnType<typeof getWordCount> => _rozieExposeRef.current.getWordCount(...args), openLinkEditor: (...args: Parameters<typeof openLinkEditor>): ReturnType<typeof openLinkEditor> => _rozieExposeRef.current.openLinkEditor(...args), setLink: (...args: Parameters<typeof setLink>): ReturnType<typeof setLink> => _rozieExposeRef.current.setLink(...args), unsetLink: (...args: Parameters<typeof unsetLink>): ReturnType<typeof unsetLink> => _rozieExposeRef.current.unsetLink(...args) }), []);

  return (
    <>
    <div className={clsx("rozie-tiptap", { "is-readonly": !props.editable })} data-rozie-s-2aeee876="">
      
      {!!(props.editable && !(props.renderToolbar ?? props.slots?.['toolbar'])) && <div className={"rozie-tiptap-toolbar"} data-rozie-s-2aeee876="">
        <button type="button" className={clsx({ active: active.bold })} aria-label="Bold" onClick={toggleBold} data-rozie-s-2aeee876=""><strong data-rozie-s-2aeee876="">B</strong></button>
        <button type="button" className={clsx({ active: active.italic })} aria-label="Italic" onClick={toggleItalic} data-rozie-s-2aeee876=""><em data-rozie-s-2aeee876="">I</em></button>
        <span className={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" className={clsx({ active: active.h1 })} aria-label="Heading 1" onClick={($event) => { toggleHeading(1); }} data-rozie-s-2aeee876="">H1</button>
        <button type="button" className={clsx({ active: active.h2 })} aria-label="Heading 2" onClick={($event) => { toggleHeading(2); }} data-rozie-s-2aeee876="">H2</button>
        <span className={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" className={clsx({ active: active.bulletList })} aria-label="Bullet list" onClick={toggleBulletList} data-rozie-s-2aeee876="">• List</button>
        <button type="button" className={clsx({ active: active.underline })} aria-label="Underline" onClick={toggleUnderline} data-rozie-s-2aeee876=""><u data-rozie-s-2aeee876="">U</u></button>
        <button type="button" className={clsx({ active: active.orderedList })} aria-label="Ordered list" onClick={toggleOrderedList} data-rozie-s-2aeee876="">1. List</button>
        <span className={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" className={clsx({ active: active.link })} aria-label="Link" onClick={openLinkEditor} data-rozie-s-2aeee876="">Link</button>
        <span className={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" aria-label="Undo" onClick={undo} data-rozie-s-2aeee876="">↺</button>
        <button type="button" aria-label="Redo" onClick={redo} data-rozie-s-2aeee876="">↻</button>
      </div>}{!!(props.editable && (props.renderToolbar ?? props.slots?.['toolbar'])) && <div className={"rozie-tiptap-toolbar rozie-tiptap-toolbar--slot"} ref={toolbarEl} data-rozie-s-2aeee876="" />}<div ref={editorEl} className={"rozie-tiptap-content"} data-placeholder={props.placeholder} data-rozie-s-2aeee876="" />
      
      {!!(props.maxLength != null || (props.renderCount ?? props.slots?.['count'])) && <div className={"rozie-tiptap-count"} data-rozie-s-2aeee876="">
        {(props.renderCount ?? props.slots?.['count']) ? ((props.renderCount ?? props.slots?.['count']) as Function)({ characters: count.characters, words: count.words, maxLength: props.maxLength, over: props.maxLength != null && count.characters > props.maxLength }) : <span className={clsx("rozie-tiptap-count-value", { over: props.maxLength != null && count.characters > props.maxLength })} data-rozie-s-2aeee876="">{rozieDisplay(count.characters)} / {props.maxLength}</span>}
      </div>}</div>









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

<div :class="['rozie-tiptap', { 'is-readonly': !props.editable }]">
  
  <div v-if="props.editable && !$slots.toolbar" class="rozie-tiptap-toolbar">
    <button type="button" :class="{ active: active.bold }" aria-label="Bold" @click="toggleBold"><strong>B</strong></button>
    <button type="button" :class="{ active: active.italic }" aria-label="Italic" @click="toggleItalic"><em>I</em></button>
    <span class="sep"></span>
    <button type="button" :class="{ active: active.h1 }" aria-label="Heading 1" @click="toggleHeading(1)">H1</button>
    <button type="button" :class="{ active: active.h2 }" aria-label="Heading 2" @click="toggleHeading(2)">H2</button>
    <span class="sep"></span>
    <button type="button" :class="{ active: active.bulletList }" aria-label="Bullet list" @click="toggleBulletList">• List</button>
    <button type="button" :class="{ active: active.underline }" aria-label="Underline" @click="toggleUnderline"><u>U</u></button>
    <button type="button" :class="{ active: active.orderedList }" aria-label="Ordered list" @click="toggleOrderedList">1. List</button>
    <span class="sep"></span>
    <button type="button" :class="{ active: active.link }" aria-label="Link" @click="openLinkEditor">Link</button>
    <span class="sep"></span>
    <button type="button" aria-label="Undo" @click="undo">↺</button>
    <button type="button" aria-label="Redo" @click="redo">↻</button>
  </div><div v-if="props.editable && $slots.toolbar" class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" ref="toolbarElRef"></div><div ref="editorElRef" class="rozie-tiptap-content" :data-placeholder="props.placeholder"></div>
  
  <div v-if="props.maxLength != null || $slots.count" class="rozie-tiptap-count">
    <slot name="count" :characters="count.characters" :words="count.words" :maxLength="props.maxLength" :over="props.maxLength != null && count.characters > props.maxLength">
      <span :class="['rozie-tiptap-count-value', { over: props.maxLength != null && count.characters > props.maxLength }]">{{ count.characters }} / {{ props.maxLength }}</span>
    </slot>
  </div></div>










</template>

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

const props = withDefaults(
  defineProps<{
    /**
     * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
     */
    editable?: boolean;
    /**
     * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
     */
    placeholder?: string;
    /**
     * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
     */
    autofocus?: boolean;
    /**
     * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
     */
    editorClass?: string;
    /**
     * The accessible name (`aria-label`) applied to the contenteditable element.
     */
    ariaLabel?: string;
    /**
     * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
     */
    editorProps?: Record<string, any>;
    /**
     * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
     */
    extensions?: any[];
    /**
     * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
     */
    starterKit?: Record<string, any>;
    /**
     * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
     * @example
     * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
     */
    nodeSpecs?: any[];
    /**
     * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
     * @example
     * <TipTap :upload-image="uploadFn" />
     */
    uploadImage?: ((...args: any[]) => any) | null;
    /**
     * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
     * @example
     * <TipTap :max-length="500" />
     */
    maxLength?: number | null;
    /**
     * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
     */
    enforceMaxLength?: boolean;
    /**
     * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
     * @example
     * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
     */
    bubbleMenuShouldShow?: ((...args: any[]) => any) | null;
  }>(),
  { editable: true, placeholder: '', autofocus: false, editorClass: '', ariaLabel: 'Rich text editor', editorProps: () => ({}), extensions: () => [], starterKit: () => ({}), nodeSpecs: () => [], uploadImage: null, maxLength: null, enforceMaxLength: false, bubbleMenuShouldShow: null }
);

/**
 * The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
 * @example
 * <TipTap r-model:html="content" placeholder="Start writing…" />
 */
const html = defineModel<string>('html', { default: '<p>Start writing…</p>' });

const emit = defineEmits<{
  update: [...args: any[]];
  selectionUpdate: [...args: any[]];
  focus: [...args: any[]];
  blur: [...args: any[]];
}>();

defineSlots<{
  count(props: { characters: any; words: any; maxLength: any; over: any }): any;
  toolbar(props: { editor: any }): any;
  bubbleMenu(props: { editor: any }): any;
  floatingMenu(props: { editor: any }): any;
  linkEditor(props: { editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any }): any;
  nodeView(props: { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any }): any;
}>();

const slots = useSlots();

const active = ref({
  bold: false,
  italic: false,
  h1: false,
  h2: false,
  bulletList: false,
  underline: false,
  orderedList: false,
  link: false
});
const count = ref({
  characters: 0,
  words: 0
});
const linkState = ref({
  href: '',
  attrs: {}
});

const toolbarElRef = ref<HTMLElement>();
const editorElRef = ref<HTMLElement>();

import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';
// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).
let editor: any = null;
// The raw HTML string the editor currently reflects. Compared against in the
// $props.html reconciler so the watcher's mount-time fire is a no-op: the
// editor is created with `content: $props.html`, so right after mount the bound
// model already matches and setContent must NOT re-run (re-running it replaces
// the whole ProseMirror document and resets the selection — the official
// @tiptap/* wrappers guard the same way against the *raw* value, never against
// the normalized `editor.getHTML()`). This is the CodeMirror suppress-echo
// guard in HTML-string form (flatpickr lineage).
let lastHtml: any = null;
// The `toolbar` portal slot's dispose handle. COMPONENT-scope (top-level let),
// NOT a $onMount-local — the Solid emitter hoists the $onMount-returned cleanup
// into a sibling onCleanup() OUTSIDE the mount-body IIFE, so a mount-local would
// lose scope there (the Chart.js tooltipEl/tooltipDispose hoist lesson).
let toolbarDispose: any = null;
// The `bubbleMenu` / `floatingMenu` portal-slot dispose handles + the imperatively
// created menu host elements. COMPONENT-scope for the same hoist reason as
// toolbarDispose — and the host els must be reachable from BOTH the pre-`new
// Editor` extension build (the menu extension needs its `element` at construction)
// AND the post-construction portal mount, so they live here too (not $onMount
// locals). Each stays null when its slot is unfilled (zero overhead, no $portals
// reference fired — the nodeView discipline).
let bubbleMenuEl: any = null;
let bubbleMenuDispose: any = null;
let floatingMenuEl: any = null;
let floatingMenuDispose: any = null;
// ── Link editor (#2) surface. Its OWN dedicated bubble-menu instance (distinct
// `pluginKey: 'rozieLinkEditor'`) with a link-aware trigger, orthogonal to the
// general `bubbleMenu` slot. `linkEditorEl` is the imperatively-created host handed
// to that BubbleMenu extension (the bubbleMenuEl discipline — engine owns
// positioning). COMPONENT-scope for the same hoist reason as the menu els.
//   - When the consumer fills the `#linkEditor` slot → `linkEditorHandle` is the
//     REACTIVE portal handle ({ update, dispose }); refreshLink() re-renders it in
//     place (Spike 016 proved a reactive portal survives the bubble-menu
//     extension's element.remove()/appendChild detach-reattach cycles).
//   - Otherwise → the component builds its OWN default form imperatively into
//     `linkEditorEl` (`linkInputEl` = its URL <input>); refreshLink() imperatively
//     refreshes the input value. Pure-script ⇒ byte-identical across all 6 targets,
//     no framework-reconciliation risk, and no portal default-content (the emitter
//     renders none for an unfilled portal slot).
// `openFlag` = the toolbar Link button's create-mode trigger (set true on click,
// cleared on Apply/Remove/Cancel/blur); the link-aware shouldShow shows the editor
// when `editor.isActive('link')` (edit mode) OR `openFlag` (create mode).
let linkEditorEl: any = null;
let linkEditorHandle: any = null;
let linkInputEl: any = null;
let openFlag = false;
// Last link state refreshLink() reflected, as a compare key — lets refreshLink
// early-return when the link mark is unchanged (a keystroke fires BOTH onUpdate
// and onSelectionUpdate, so refreshLink would otherwise run — and re-render the
// #linkEditor fragment — twice per keystroke).
let lastLinkKey: any = null;
// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
  if (!editor) return;
  active.value = {
    bold: editor.isActive('bold'),
    italic: editor.isActive('italic'),
    h1: editor.isActive('heading', {
      level: 1
    }),
    h2: editor.isActive('heading', {
      level: 2
    }),
    bulletList: editor.isActive('bulletList'),
    underline: editor.isActive('underline'),
    orderedList: editor.isActive('orderedList'),
    link: editor.isActive('link')
  };
};
// ── Link editor (#2) command helpers + reactive refresh. TOP-LEVEL const arrows
// (siblings of refreshActive/refreshCount) so every `editor` read sits at the same
// shallow, proven-safe depth — never nested inside an object-literal method (the
// redirectNestedThis gap [[project_emitter_redirect_nested_this_gap]]). The link
// scope's setLink/unsetLink/close are these top-level fns, referenced by identity
// from buildLinkScope so the consumer fragment (and the built-in form) call the
// SAME verbs. `extendMarkRange('link')` widens the selection to the whole link so
// an edit/removal applies to the entire mark, not just the caret word.
//
// DECLARATION ORDER IS LOAD-BEARING (topological, leaves first): apply/remove/close
// → buildLinkScope → refreshLink → openLinkEditor. The React/Solid/Lit emitters lift
// reactive closures into useCallback/memo with eager dependency ARRAYS, so a forward
// reference to a later-declared reactive const is a hard TS2448 (use-before-decl) —
// unlike a deferred function BODY, which is fine. apply/removeLink therefore do NOT
// call refreshLink (which would make them depend on it and re-introduce a cycle):
// the setLink/unsetLink chain dispatches a transaction that fires onSelectionUpdate +
// onUpdate, both of which already call refreshLink. Only openLinkEditor (safely last)
// calls it, for immediate prefill on the create affordance.
const applyLink = (attrs: any) => {
  // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
  // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
  if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
  editor?.chain().focus().extendMarkRange('link').setLink(attrs).run();
  openFlag = false;
};
const removeLink = () => {
  editor?.chain().focus().extendMarkRange('link').unsetLink().run();
  openFlag = false;
};
// Force the link-editor BubbleMenu to open/close right now, matching openFlag
// (bug fix — quick 260809-6zp). `editor?.commands.focus()` alone is NOT
// sufficient: TipTap's `focus` command early-returns with NO dispatch whenever
// `view.hasFocus() && position === null` (the whole document is already
// focused — the COMMON case once the create/close affordance is invoked from
// a `@mousedown.prevent`-guarded control, which deliberately never blurs the
// editor). And even a dispatched but otherwise-INERT transaction (no doc/
// selection change) is not enough either: @tiptap/extension-bubble-menu's own
// `update()` short-circuits with `isSame = !selectionChanged && !docChanged`
// BEFORE it ever re-runs `shouldShow` — so a no-op dispatch is silently
// swallowed by the extension's OWN guard, not just TipTap's `focus` command.
// The extension's `transactionHandler` (its own doc comment: "This allows
// external code to trigger ... via `editor.view.dispatch(editor.state.tr
// .setMeta(pluginKey, 'updatePosition'))`") is the official escape hatch: a
// transaction tagged with THIS surface's own `pluginKey` ('rozieLinkEditor')
// calls `show()`/`hide()` directly, bypassing both guards. This bit both
// `openLinkEditor` (create-mode toolbar button) and `closeLink` (built-in
// Cancel AND any consumer `close()`), on every target, whenever the editor
// was already focused. `editor` is the raw TipTap `Editor` instance on all 6.
const forceMenuRecheck = () => {
  if (!editor) return;
  const visible = editor.isEditable && (editor.isActive('link') || openFlag);
  editor.view.dispatch(editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
};
const closeLink = () => {
  openFlag = false;
  // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
  // current link href. The surface itself is link-anchored (like Google Docs) — it
  // stays while the caret is on a link and hides once openFlag is clear and the
  // caret is off any link (or the doc is not editable).
  if (linkInputEl) linkInputEl.value = linkState.value.href;
  editor?.commands.focus();
  forceMenuRecheck();
};
// The reactive `#linkEditor` slot scope — keys EXACTLY { editor, href, attrs,
// setLink, unsetLink, close } (spec §5.3). `attrs` is the raw link mark attrs
// object so a consumer can read custom attrs (e.g. data-course-link); setLink
// forwards whatever attrs object it is handed VERBATIM (REQ-42 — persistence of a
// custom attr is the consumer's Link.extend concern, not this wrapper's).
//
// Takes `href`/`attrs` as PARAMETERS rather than reading `$data.linkState` —
// every caller has just computed (or is about to compute) these values
// directly from the live editor, and reading them back off `$data`
// immediately after a same-tick write hits the React setState-is-async
// stale-read trap (the D-04 prefill fix's own class of bug, here on the
// ONGOING reactive-refresh path rather than the one-time mount path). Passing
// them straight through keeps every target reading the value that was ACTUALLY
// just computed, not a framework-buffered echo of it.
const buildLinkScope = (href: any, attrs: any) => ({
  editor,
  href,
  attrs,
  setLink: applyLink,
  unsetLink: removeLink,
  close: closeLink
});
// Recompute link state from the live editor + drive the surface. Called from
// onSelectionUpdate + onUpdate (and after content sets). When the consumer slot is
// filled, re-render the reactive portal in place; otherwise refresh the built-in
// form's input value — but NOT while the user is typing in it (don't stomp mid-edit).
const refreshLink = () => {
  if (!editor) return;
  const a = editor.getAttributes('link');
  const href = a.href || '';
  // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
  // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
  // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
  // change the link).
  const key = href + '' + JSON.stringify(a);
  if (key === lastLinkKey) return;
  lastLinkKey = key;
  linkState.value = {
    href,
    attrs: a
  };
  if (linkEditorHandle) {
    linkEditorHandle.update(buildLinkScope(href, a));
  } else if (linkInputEl && !linkInputEl.matches(':focus')) {
    // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
    // the user typing in this input?" guard holds inside a shadow root — on the Lit
    // target document.activeElement is the shadow HOST, so a document.activeElement
    // check would always miss and stomp the user's in-progress URL. `:focus` is
    // per-element and shadow-boundary-agnostic.
    linkInputEl.value = href;
  }
};
// Toolbar Link button (create affordance, ask C's deferred button): flip the
// open flag so the link-aware shouldShow surfaces the editor on the current
// selection, prefilled with any existing href. Declared AFTER refreshLink so its
// reactive dep array references an already-declared const (see order note above).
const openLinkEditor = () => {
  openFlag = true;
  editor?.commands.focus();
  refreshLink();
  forceMenuRecheck();
};
// Build the batteries-included default link-editor form imperatively into the
// engine-managed host (the bubble-menu extension owns positioning). Vanilla DOM
// so it is byte-identical across all 6 targets and the framework never reconciles
// it. Enter = Apply, Escape = Cancel. Used ONLY when the `#linkEditor` slot is
// unfilled; a filled slot renders the consumer fragment via the reactive portal.
const buildDefaultLinkEditor = (el: any) => {
  const input = document.createElement('input');
  input.type = 'text';
  input.className = 'rozie-tiptap-link-input';
  input.placeholder = 'https://…';
  const apply = document.createElement('button');
  apply.type = 'button';
  apply.className = 'rozie-tiptap-link-apply';
  apply.textContent = 'Apply';
  const remove = document.createElement('button');
  remove.type = 'button';
  remove.className = 'rozie-tiptap-link-remove';
  remove.textContent = 'Remove';
  const cancel = document.createElement('button');
  cancel.type = 'button';
  cancel.className = 'rozie-tiptap-link-cancel';
  cancel.textContent = 'Cancel';
  // Keep the caret/selection in the document when a control is pressed (a plain
  // click would blur the editor and collapse the selection before the command runs).
  const keepFocus = (e: any) => e.preventDefault();
  for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
  apply.addEventListener('click', () => applyLink({
    href: input.value
  }));
  remove.addEventListener('click', removeLink);
  cancel.addEventListener('click', closeLink);
  input.addEventListener('keydown', (e: any) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      applyLink({
        href: input.value
      });
    } else if (e.key === 'Escape') {
      e.preventDefault();
      closeLink();
    }
  });
  el.appendChild(input);
  el.appendChild(apply);
  el.appendChild(remove);
  el.appendChild(cancel);
  linkInputEl = input;
};
// Recompute the character/word counter from the live editor (D-05). Robust to
// CharacterCount being absent (maxLength unset, no #count slot): reads
// `editor.storage.characterCount` when the extension is registered, else falls
// back to a plain text derivation so `getCharacterCount`/`getWordCount` and the
// #count slot's numbers are never stale.
const refreshCount = () => {
  if (!editor) return;
  const storage = editor.storage.characterCount;
  count.value = {
    characters: storage ? storage.characters() : editor.getText().length,
    words: storage ? storage.words() : editor.getText().split(/\s+/).filter(Boolean).length
  };
};
// ── StarterKit collision-aware config (ask A). StarterKit bundles several
// node/mark extensions INTERNALLY (invisible to a top-level array dedup) —
// e.g. its own `Link`. A consumer supplying a custom same-named extension via
// `extensions` therefore collides with StarterKit's copy and TipTap warns
// "Duplicate extension names found" while keeping BOTH; only
// `StarterKit.configure({ link:false })` actually disables StarterKit's. This
// map + helper make "consumer wins" true by auto-disabling the StarterKit key
// whenever the consumer supplies a same-named extension AND has not already
// decided that key's fate via the `starterKit` prop. Identity for the 15
// node/mark keys StarterKit exposes as `Partial<Options> | false`, plus the
// undo/redo option key `undoRedo` — mapped from BOTH its actual installed
// `.name` (`'undoRedo'`, verified against `@tiptap/extensions@3.23.5`) and the
// TipTap v2 alias `'history'` as a safety net for a consumer porting a v2
// History extension. Structural/plumbing StarterKit keys (document, text,
// dropcursor, gapcursor, listKeymap, trailingNode) are NOT node/mark
// replacements and are intentionally excluded.
const STARTERKIT_COLLISION_MAP = {
  bold: 'bold',
  italic: 'italic',
  strike: 'strike',
  code: 'code',
  heading: 'heading',
  paragraph: 'paragraph',
  blockquote: 'blockquote',
  codeBlock: 'codeBlock',
  hardBreak: 'hardBreak',
  horizontalRule: 'horizontalRule',
  bulletList: 'bulletList',
  orderedList: 'orderedList',
  listItem: 'listItem',
  link: 'link',
  underline: 'underline',
  undoRedo: 'undoRedo',
  history: 'undoRedo'
};
// Pure helper — returns `userConfig` extended so any StarterKit-bundled
// node/mark the consumer replaced (a same-named entry in `exts`) is disabled
// UNLESS the consumer already decided that key's fate in `userConfig` (an `in`
// presence check, so an explicit `false` OR an explicit options object both
// count as "consumer decided" — D-02, consumer wins unless configured
// explicitly). Never invokes consumer code — only reads `.name` and does key
// presence checks (guards a non-object/missing `.name` entry by skipping it).
const buildStarterKitConfig = (userConfig: any, exts: any) => {
  const effective = {
    ...userConfig
  };
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    if (typeof name !== 'string') continue;
    const optionKey = STARTERKIT_COLLISION_MAP[name];
    if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
  }
  return effective;
};
// Pure helper — D-03 last-wins safety net over the FINAL assembled extension
// array. Dedupes by `.name`, keeping the LAST occurrence (later = consumer).
// A nameless/unnamed entry is never collapsed against another nameless entry
// — each survives, keyed by a per-entry unique fallback rather than a shared
// `undefined` key.
const dedupeExtensionsByName = (exts: any) => {
  const byKey = new Map();
  let anonSeq = 0;
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
    byKey.set(key, ext);
  }
  return [...byKey.values()];
};
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator; generalized in Phase
// 260719-d9e / ask B). When the consumer fills the `nodeView` slot AND
// supplies one or more `nodeSpecs`, each spec becomes its own custom
// ProseMirror node rendering the SAME consumer fragment as a custom node
// *in-engine*, re-rendering it in place on every transaction via the
// reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). The fragment dispatches on `scope.node.type.name` to tell the
// specs apart (D-03 — single-slot-dispatch, no dynamic per-type slot names).
//
// A spec with NO `content` (typically `atom:true`) is a NON-EDITABLE node —
// no contentDOM — driven purely by selectNode/deselectNode/update(node) →
// handle.update so the fragment re-renders in place (engine-driven; no Rozie
// reactive loop). Proven originally by the @mention-chip recipe (Spike 009 /
// REQ-26), now shipped as a `nodeSpecs` entry in the example demos.
//
// A spec WITH `content` (e.g. `'inline*'`) is an EDITABLE BLOCK — it HAS a
// contentDOM. ProseMirror owns the editable hole; the consumer fragment
// renders chrome wrapping a [data-rozie-hole] placeholder and the per-target
// portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here. Proven
// originally by the editable-callout recipe (Spike 008 / REQ-23), now shipped
// as a `nodeSpecs` entry in the example demos.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv: any, spec: any) => (props: any) => {
  const {
    node,
    getPos,
    editor: ed
  } = props;
  // hasContentDOM derives from the spec, not a bare boolean: an editable node
  // is one that is NOT an atom and declares `content` (e.g. 'inline*').
  const hasContentDOM = !spec.atom && !!spec.content;
  // engine-owned outer host the consumer fragment mounts into.
  const dom = document.createElement(hasContentDOM ? 'div' : 'span');
  dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
  // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
  // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
  const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
  if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
  const updateAttributes = (attrs: any) => {
    if (typeof getPos !== 'function') return;
    const pos = getPos();
    if (pos == null) return;
    ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
      ...node.attrs,
      ...attrs
    }));
  };
  const buildScope = (n: any, selected: any) => ({
    node: n,
    selected,
    updateAttributes,
    getPos,
    editor: ed,
    ...(contentDOM ? {
      contentDOM
    } : {})
  });

  // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
  // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
  const handle = nv(dom, buildScope(node, false));

  // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
  // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
  // ProseMirror manages `contentDOM` and renders the node's editable children
  // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
  // rendered into `dom` by the per-target reactive portal — synchronously on
  // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
  // Angular (REQ-23). A query-after-render graft (retried across a microtask +
  // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
  // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
  // and the framework never reconciles it away (the hole carries no child binding).
  const graftContentDOM = (attempt: any) => {
    if (!contentDOM) return;
    const hole = dom.querySelector('[data-rozie-hole]');
    if (hole) {
      if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
      return;
    }
    if (attempt < 5) {
      if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
    }
  };
  graftContentDOM(0);

  // After a reactive re-render (chrome update), re-graft so a fragment that
  // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
  // (REQ-24 — the editable subtree survives every chrome update).
  const updateInPlace = (n: any, selected: any) => {
    handle.update(buildScope(n, selected));
    if (contentDOM) graftContentDOM(0);
  };
  return {
    dom,
    ...(contentDOM ? {
      contentDOM
    } : {}),
    // attr / content change for THIS node → re-render the fragment in place,
    // keep the view (return true). The new node identity is forwarded so the
    // fragment reads fresh node.attrs (REQ-26).
    update(nextNode: any) {
      if (nextNode.type !== node.type) return false;
      updateInPlace(nextNode, false);
      return true;
    },
    // NodeSelection enters/leaves the node → toggle `selected` in scope so the
    // chip's selected styling is pure engine-driven reactive `update`.
    selectNode() {
      updateInPlace(node, true);
    },
    deselectNode() {
      updateInPlace(node, false);
    },
    destroy() {
      handle.dispose();
    }
  };
};
// Pure helper (ask B, D-02) — extracts { el, attr, value } from a parseHTML
// tag selector string, e.g. 'span[data-x]' → { el: 'span', attr: 'data-x',
// value: '' } or 'div[data-x=y]' → { el: 'div', attr: 'data-x', value: 'y' }.
// Drives renderHTML's marker attribute so the serialized element reproduces
// the exact shape the parseHTML rule expects. MUST NOT throw on a
// malformed/empty selector (T-d9e-01 — a bad selector degrades only that one
// node's render, never crashes the editor): falls back to el = the raw
// selector (or 'span' if falsy), attr = null (no marker), value = ''.
const parseTagSelector = (selector: any) => {
  const raw = typeof selector === 'string' ? selector : '';
  const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
  const el = elMatch ? elMatch[0] : raw || 'span';
  const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
  if (!attrMatch) return {
    el,
    attr: null,
    value: ''
  };
  const attr = (attrMatch[1] ?? '').trim();
  const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
  return {
    el,
    attr,
    value
  };
};
// Build ONE custom Node per consumer-supplied spec, all bound to the SAME
// reactive nodeView portal (ask B, D-02). Takes the per-target
// `$portals.nodeView` (captured here so the reference stays inside the mount
// lifecycle — never top-level, per the bundled-leaf typecheck rule) and the
// `nodeSpecs` prop array (read once at mount — setup-once, not reactive).
const makeNodeViewExtensions = (nv: any, specs: any) => specs.map((spec: any) => {
  // hasContentDOM decides the renderHTML hole: an editable (non-atom,
  // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
  // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
  // a leaf node spec" otherwise).
  const hasContentDOM = !spec.atom && !!spec.content;
  const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
  const {
    el,
    attr,
    value
  } = parseTagSelector(firstTag);
  return Node.create({
    name: spec.name,
    group: spec.group ?? 'block',
    inline: spec.inline ?? false,
    atom: spec.atom ?? false,
    selectable: spec.selectable ?? true,
    defining: spec.defining ?? false,
    ...(spec.content ? {
      content: spec.content
    } : {}),
    addAttributes: () => spec.attrs ?? {},
    parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
      tag: t
    })),
    renderHTML: ({
      HTMLAttributes
    }: any) => hasContentDOM ? [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }, 0] : [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }],
    addNodeView: () => makeNodeView(nv, spec)
  });
});
// Shared image-file finder for the upload handlers below — the first
// `image/*` File in a FileList, else undefined. Guards a missing FileList.
const findImageFile = (files: any) => {
  if (!files) return undefined;
  for (let i = 0; i < files.length; i++) {
    const f = files[i];
    if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
  }
  return undefined;
};
// uploadImage paste/drop fallbacks (ask D / D-04) — ProseMirror `editorProps`
// handlers. TOP-LEVEL functions (siblings of `refreshActive`/the $expose
// verbs below), NOT nested inside $onMount's ternary/object-literal — a
// closure reading the component-scope `editor` from several function-levels
// deep inside $onMount (object-literal method → `.then` callback) hits a
// `this`-rebinding gap on the class-based targets (Angular/Lit) that the
// emitter's nested-`this` repair does not reach at that depth
// (emitter-backlog). A top-level function is only ONE level removed from the
// promoted-`this` boundary — the same shallow depth as the `onUpdate` /
// `$watch` callbacks elsewhere in this file, which already compile clean —
// so referencing `editor` here needs no repair at all. Each handler claims
// ONLY an image/* payload: returns `true` SYNCHRONOUSLY (claiming the
// paste/drop now — never awaits inside the handler) and inserts the resolved
// URL once the consumer's uploadImage promise settles; a rejection is
// swallowed (`.catch(() => {})`) so a failed upload never crashes the editor
// (T-e7i-01). Returns `false` for a non-image payload — or, for drop, an
// internal node move — so ProseMirror (or a consumer editorProps handler,
// which still wins via the LAST spread) processes it normally.
function handlePaste(view: any, event: any, slice: any) {
  // Captured into a local (not repeated `$props.uploadImage` member reads) so
  // the null-check narrows the type on every target — including Lit, where
  // the Function prop lowers to a nullable function type and a bare
  // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
  // typecheck (TS2721) even though this handler is only ever wired into
  // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
  // gate already guarantees this in practice).
  const upload = props.uploadImage;
  if (!upload) return false;
  const file = findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
  if (!file) return false;
  event.preventDefault();
  upload(file).then((url: any) => {
    editor?.chain().focus().setImage({
      src: url
    }).run();
  }).catch(() => {});
  return true;
}
function handleDrop(view: any, event: any, slice: any, moved: any) {
  if (moved) return false;
  // See handlePaste — local capture for the same cross-target null-narrowing.
  const upload = props.uploadImage;
  if (!upload) return false;
  const file = findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
  if (!file) return false;
  event.preventDefault();
  const pos = view.posAtCoords({
    left: event.clientX,
    top: event.clientY
  });
  upload(file).then((url: any) => {
    const insertPos = pos ? pos.pos : editor ? editor.state.selection.head : 0;
    editor?.chain().focus().insertContentAt(insertPos, {
      type: 'image',
      attrs: {
        src: url
      }
    }).run();
  }).catch(() => {});
  return true;
}
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 25 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
//   - The content setter is named `setContent`, NOT `setHtml` — an `html` model
//     prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
//     $expose verb would collide on the React target (ROZ524). (CodeMirror's
//     setValue→replaceValue lesson, html edition.)
//   - None of the 25 names collide with LitElement reserved lifecycle methods
//     (update/render/firstUpdated/updated/willUpdate/requestUpdate).
//   - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
//     `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
//     class-based targets (Angular) an output field and a method cannot share a
//     name (ROZ121). The diagnostic's own guidance: rename the method, keep the
//     event's public name. (The expose-verb-vs-event-name collision lesson.)
//   - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
//     ariaLabel/editorProps/extensions).
function getEditor() {
  return editor;
}
function focusEditor() {
  editor?.commands.focus();
}
function blurEditor() {
  editor?.commands.blur();
}
function getHTML() {
  return editor ? editor.getHTML() : '';
}
function getJSON() {
  return editor ? editor.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText() {
  return editor ? editor.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next: any) {
  if (!editor) return;
  const v = next ?? '';
  if (v === lastHtml) return;
  lastHtml = v;
  editor.commands.setContent(v, {
    emitUpdate: false
  });
  html.value = v;
  refreshActive();
  refreshCount();
  refreshLink();
}
function clearContent() {
  if (!editor) return;
  editor.commands.clearContent();
  lastHtml = editor.getHTML();
  html.value = lastHtml;
  refreshActive();
  refreshCount();
  refreshLink();
}
function toggleBold() {
  editor?.chain().focus().toggleBold().run();
  refreshActive();
}
function toggleItalic() {
  editor?.chain().focus().toggleItalic().run();
  refreshActive();
}
function toggleHeading(level: any) {
  editor?.chain().focus().toggleHeading({
    level: level ?? 1
  }).run();
  refreshActive();
}
function toggleBulletList() {
  editor?.chain().focus().toggleBulletList().run();
  refreshActive();
}
function toggleUnderline() {
  editor?.chain().focus().toggleUnderline().run();
  refreshActive();
}
function toggleOrderedList() {
  editor?.chain().focus().toggleOrderedList().run();
  refreshActive();
}
function undo() {
  editor?.chain().focus().undo().run();
  refreshActive();
}
function redo() {
  editor?.chain().focus().redo().run();
  refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain() {
  return editor ? editor.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
//   - isActive(name, attrs?): is a mark/node active in the current selection
//     (drive toolbar button active styling). False before mount.
//   - can(): the command-availability chain (editor.can().chain()…run()) for
//     enable/disable of toolbar buttons. null before mount (mirrors chain()).
//   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name: any, attrs: any) {
  return editor ? editor.isActive(name, attrs) : false;
}
function can() {
  return editor ? editor.can() : null;
}
function isEmpty() {
  return editor ? editor.isEmpty : true;
}
// Character/word count reads (D-04). Prefer the CharacterCount extension's live
// storage when registered (maxLength set or #count slot filled); otherwise a
// text-based fallback so these ALWAYS return a number — 0 before mount, and a
// correct count even on a stock <TipTap> that never registered CharacterCount.
function getCharacterCount() {
  if (!editor) return 0;
  return editor.storage.characterCount ? editor.storage.characterCount.characters() : editor.getText().length;
}
function getWordCount() {
  if (!editor) return 0;
  return editor.storage.characterCount ? editor.storage.characterCount.words() : editor.getText().split(/\s+/).filter(Boolean).length;
}
// setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
// applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
// (buildLinkScope above), so the imperative handle and the slot-scope verb
// implementation cannot disagree. Four-way collision check:
//   - not a prop name — the 14 props are html / editable / placeholder /
//     autofocus / editorClass / ariaLabel / editorProps / extensions /
//     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
//     bubbleMenuShouldShow;
//   - not an emitted event name — the 4 events are update / selectionUpdate /
//     focus / blur (the ROZ121 Angular output-field-vs-method rule);
//   - not an existing $expose verb — the 23 names already in the object below;
//   - not a React auto-generated model setter — the only model prop is
//     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
//     `setContent`), and not a LitElement lifecycle method (update / render /
//     firstUpdated / updated / willUpdate / requestUpdate).
// applyLink already ignores an attrs object without a non-empty string href
// (no degenerate empty-href anchor is ever written), and both verbs no-op
// before mount / after destroy through the `editor?.` guards already inside
// applyLink/removeLink — no second validation path is introduced.
function setLink(attrs: any) {
  applyLink(attrs);
}
function unsetLink() {
  removeLink();
}

interface ReactivePortalHandle {
  update(scope: unknown): void;
  dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
  toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    const slotFn = slots.toolbar;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // toolbar { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    const slotFn = slots.bubbleMenu;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // bubbleMenu { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    const slotFn = slots.floatingMenu;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // floatingMenu { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
  linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
    const slotFn = slots.linkEditor;
    if (!slotFn) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // linkEditor { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
    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);
      },
    };
  },
  nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
    const slotFn = slots.nodeView;
    if (!slotFn) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // nodeView { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
    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(() => {
  lastHtml = html.value;

  // Register the reactive node-view nodes ONLY when the consumer fills the
  // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
  // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
  // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
  // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
  // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
  // here inside the mount body and passed into the node factory, keeping the
  // reference scoped to the mount lifecycle (the toolbar-slot discipline).
  const nodeViewExtensions = slots.nodeView && props.nodeSpecs.length ? makeNodeViewExtensions(portals.nodeView, props.nodeSpecs) : [];

  // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
  // (setup-once, like content/editable/autofocus — no reactivity required). The
  // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
  // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
  // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
  // (in the style block) paints the ghost text. Empty placeholder = no extension.
  const placeholderExtensions = props.placeholder ? [Placeholder.configure({
    placeholder: props.placeholder
  })] : [];

  // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
  // Floating-UI menu extension needs its host `element` at construction time. Each
  // menu's host element is created imperatively (the nodeView discipline — the
  // engine owns positioning; the consumer fragment is portalled in AFTER mount).
  // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
  //
  // The host elements are created up front (when filled) so they're captured into
  // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
  // portal mount; the extension list is then assembled by conditional SPREAD (NOT
  // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
  // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
  // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
  if (slots.bubbleMenu) {
    bubbleMenuEl = document.createElement('div');
    bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
  }
  if (slots.floatingMenu) {
    floatingMenuEl = document.createElement('div');
    floatingMenuEl.className = 'rozie-tiptap-floating-menu';
  }
  // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
  // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
  // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
  // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
  // the link-editor shouldShow below — a live check that follows a runtime toggle.
  // This closes both directions of the mount-time-gate bug: a doc mounted readonly
  // that later becomes editable gets a working link editor, and a doc toggled TO
  // readonly can no longer be link-edited (isEditable false → never shows, so no
  // Apply/Remove on a read-only document).
  linkEditorEl = document.createElement('div');
  linkEditorEl.className = 'rozie-tiptap-link-editor';
  // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
  // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
  // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
  // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
  // (non-empty text selection). The link editor's shouldShow is link-aware: show
  // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
  // by design so it never fires on a bare selection and collide with the general one.
  const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
    pluginKey: 'rozieBubbleMenu',
    element: bubbleMenuEl,
    ...(props.bubbleMenuShouldShow ? {
      shouldShow: props.bubbleMenuShouldShow
    } : {})
  })] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
    element: floatingMenuEl
  })] : []), ...(linkEditorEl ? [BubbleMenu.configure({
    pluginKey: 'rozieLinkEditor',
    element: linkEditorEl,
    // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
    // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
    // button set openFlag (create) — never on a bare selection.
    shouldShow: ({
      editor
    }: any) => editor.isEditable && (editor.isActive('link') || openFlag)
  })] : [])];

  // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
  // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
  // absent: no Image extension, no paste/drop handlers (zero overhead, the
  // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
  // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
  const imageExtensions = props.uploadImage ? [Image] : [];

  // Character/word count (D-01..D-03). Gated on maxLength being set OR the
  // `count` slot being filled — a stock <TipTap> with neither registers NO
  // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
  // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
  // cap); otherwise CharacterCount tracks with no limit (soft — overflow
  // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
  // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
  // never[]-inference reason as placeholderExtensions/imageExtensions.
  const needsCount = props.maxLength != null || slots.count;
  const characterCountExtensions = needsCount ? [CharacterCount.configure(props.enforceMaxLength && props.maxLength != null ? {
    limit: props.maxLength
  } : {})] : [];

  // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
  // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
  // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
  // functions declared above (see their doc comment for why they live at the
  // top level rather than as closures nested in this ternary).
  const uploadHandlers = props.uploadImage ? {
    handlePaste,
    handleDrop
  } : {};
  editor = new Editor({
    element: editorElRef.value!,
    content: html.value,
    editable: props.editable,
    autofocus: props.autofocus,
    // StarterKit first (config-disabled per the collision scan below); the
    // Placeholder ext next; the reactive node-view nodes next; consumer
    // extensions LAST so they win (TipTap applies later-registered extensions
    // over earlier ones for the same node/mark) — and the whole array is
    // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
    // of the config-level auto-disable (D-02), which is what actually silences
    // StarterKit's internal same-named extension (e.g. its bundled `Link`).
    extensions: dedupeExtensionsByName([StarterKit.configure(buildStarterKitConfig(props.starterKit, props.extensions)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ...props.extensions]),
    editorProps: {
      attributes: {
        'aria-label': props.ariaLabel,
        ...(props.editorClass ? {
          class: props.editorClass
        } : {}),
        ...(props.placeholder ? {
          'data-placeholder': props.placeholder,
          'aria-placeholder': props.placeholder
        } : {})
      },
      // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
      // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
      // `{}` (no-op) when $props.uploadImage is unset.
      ...uploadHandlers,
      // Consumer editorProps spread LAST — full ProseMirror editorProps control
      // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
      ...props.editorProps
    },
    onUpdate: ({
      editor
    }: any) => {
      const next = editor.getHTML();
      lastHtml = next;
      // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
      if (next !== html.value) html.value = next;
      refreshCount();
      refreshLink();
      emit('update', next);
    },
    onSelectionUpdate: () => {
      refreshActive();
      refreshLink();
      emit('selectionUpdate');
    },
    onFocus: () => emit('focus'),
    onBlur: ({
      event
    }: any) => {
      // Clear the create-mode latch when focus truly leaves the editor + its link
      // surface — but NOT when it moves INTO the link editor host (clicking the URL
      // input blurs the editor; the buttons are already covered by their keepFocus
      // mousedown). Without this, openFlag stays true after the user dismisses the
      // create affordance by clicking away, so the editor spuriously re-surfaces on
      // the next unrelated selection.
      const to = event && event.relatedTarget;
      if (!(to instanceof Node && linkEditorEl && linkEditorEl.contains(to))) openFlag = false;
      emit('blur');
    }
  });
  refreshActive();
  refreshCount();
  refreshLink();

  // `toolbar` portal slot — when the consumer fills it, mount their toolbar
  // fragment into the engine-adjacent host node, handing them the live editor
  // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
  // referenced ONLY here inside $onMount (the per-target portal helper is scoped
  // to the mount lifecycle — a top-level reference would fail the bundled-leaf
  // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
  // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
  if (slots.toolbar && toolbarElRef.value) {
    toolbarDispose = portals.toolbar(toolbarElRef.value!, {
      editor
    });
  }

  // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
  // fragment into the engine-owned (imperatively-created) host element handed to
  // the Floating-UI menu extension, with the live editor in scope (their buttons
  // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
  // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
  // strict-typecheck discipline). The element is created above only when the slot
  // is filled, so each portal fires exactly when its slot exists.
  if (bubbleMenuEl) {
    bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
      editor
    });
  }
  if (floatingMenuEl) {
    floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
      editor
    });
  }

  // Link editor (#2) — mount the surface into its engine-managed host. When the
  // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
  // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
  // this survives the bubble-menu extension's detach-reattach). Otherwise the
  // component's own default form is built imperatively into the same host.
  // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
  if (linkEditorEl) {
    if (slots.linkEditor) {
      // Read the initial link attrs straight off the live editor (NOT
      // `$data.linkState`, written by the refreshLink() call above in this
      // same tick) — the same React stale-read avoidance as buildLinkScope's
      // other call site.
      const initialLinkAttrs = editor.getAttributes('link');
      linkEditorHandle = portals.linkEditor(linkEditorEl, buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs));
    } else {
      buildDefaultLinkEditor(linkEditorEl);
      // Prefill correction (D-04): the refreshLink() call above (right after
      // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
      // exist yet at that point, so every LATER refreshLink() for the same
      // link early-returns, leaving the just-created input empty even when the
      // caret starts inside a link. Seed it directly from the LIVE editor
      // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
      // $data key immediately after refreshLink() just wrote it hits the
      // React setState-is-async stale-read trap (the same write-then-read-in-
      // one-handler class ROZ138 warns about elsewhere in this file), since
      // $data.linkState was written by the refreshLink() call directly above.
      // `editor` is a plain instance handle, not reactive state, so reading it
      // straight off the engine is synchronous and target-uniform. A no-link
      // mount leaves this the empty string (unchanged).
      if (linkInputEl) linkInputEl.value = editor.getAttributes('link').href || '';
    }
  }
  _cleanup_0 = () => {
    toolbarDispose?.();
    toolbarDispose = null;
    bubbleMenuDispose?.();
    bubbleMenuDispose = null;
    floatingMenuDispose?.();
    floatingMenuDispose = null;
    linkEditorHandle?.dispose();
    linkEditorHandle = null;
    linkEditorEl = null;
    linkInputEl = null;
    editor?.destroy();
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });

watch(() => html.value, (v: any) => {
  if (!editor) return;
  if (v === lastHtml) return;
  lastHtml = v;
  editor.commands.setContent(v, {
    emitUpdate: false
  });
  refreshActive();
  refreshCount();
  refreshLink();
}, { flush: 'post' });
watch(() => props.editable, (v: any) => editor?.setEditable(v, false), { flush: 'post' });

defineExpose({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList, undo, redo, chain, isActive, can, isEmpty, getCharacterCount, getWordCount, openLinkEditor, setLink, unsetLink });
</script>

<style scoped>
.rozie-tiptap {
  border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rozie-tiptap-radius, 6px);
  overflow: hidden;
  background: var(--rozie-tiptap-bg, white);
}
.rozie-tiptap.is-readonly {
  background: var(--rozie-tiptap-readonly-bg, #fafafa);
}
.rozie-tiptap-toolbar {
  display: flex;
  align-items: center;
  gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
  padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
  border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
  background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
}
.rozie-tiptap-toolbar button {
  padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
  border: var(--rozie-tiptap-button-border, 1px solid transparent);
  background: var(--rozie-tiptap-button-bg, transparent);
  border-radius: var(--rozie-tiptap-button-radius, 3px);
  cursor: pointer;
  font: inherit;
  font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
  min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
  color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
}
.rozie-tiptap-toolbar button:hover {
  background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-tiptap-toolbar button.active {
  background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
  color: var(--rozie-tiptap-button-active-color, white);
  border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
}
.rozie-tiptap-toolbar .sep {
  width: var(--rozie-tiptap-toolbar-sep-width, 1px);
  height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
  background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
  margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
}
.rozie-tiptap-content {
  padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
  min-height: var(--rozie-tiptap-content-min-height, 6rem);
  font: inherit;
  outline: none;
}
.rozie-tiptap-content p { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
.rozie-tiptap-content p:last-child { margin-bottom: 0; }
.rozie-tiptap-content h1 { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content h2 { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content ul { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }
.rozie-tiptap-count {
  display: flex;
  justify-content: flex-end;
  padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
  border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
  font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
  color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
}
.rozie-tiptap-count-value.over {
  color: var(--rozie-tiptap-count-over-color, #c0392b);
}
</style>

<style>
.rozie-tiptap-content .is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
    float: left;
    height: 0;
    pointer-events: none;
  }
.rozie-tiptap-link-editor {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-link-gap, 0.25rem);
    padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
    background: var(--rozie-tiptap-link-bg, #1a1a1a);
    border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rozie-tiptap-link-radius, 6px);
    box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
  }
.rozie-tiptap-link-input {
    font: inherit;
    font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
    min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
    border: var(--rozie-tiptap-link-input-border, 1px solid #444);
    border-radius: var(--rozie-tiptap-link-input-radius, 4px);
    background: var(--rozie-tiptap-link-input-bg, #fff);
    color: var(--rozie-tiptap-link-input-color, #000);
  }
.rozie-tiptap-link-editor button {
    font: inherit;
    font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
    border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
    border-radius: var(--rozie-tiptap-link-button-radius, 4px);
    background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
    color: var(--rozie-tiptap-link-button-color, #fff);
    cursor: pointer;
  }
.rozie-tiptap-link-editor button:hover {
    background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
  }
.rozie-tiptap-link-editor .rozie-tiptap-link-remove {
    color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
  }
</style>
svelte
<script lang="ts">
import { rozieDisplay } from '@rozie/runtime-svelte';

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 editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
   * @example
   * <TipTap r-model:html="content" placeholder="Start writing…" />
   */
  html?: string;
  /**
   * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
   */
  editable?: boolean;
  /**
   * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
   */
  placeholder?: string;
  /**
   * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
   */
  autofocus?: boolean;
  /**
   * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
   */
  editorClass?: string;
  /**
   * The accessible name (`aria-label`) applied to the contenteditable element.
   */
  ariaLabel?: string;
  /**
   * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
   */
  editorProps?: any;
  /**
   * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
   */
  extensions?: any[];
  /**
   * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
   */
  starterKit?: any;
  /**
   * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
   * @example
   * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
   */
  nodeSpecs?: any[];
  /**
   * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
   * @example
   * <TipTap :upload-image="uploadFn" />
   */
  uploadImage?: ((...args: any[]) => any) | null;
  /**
   * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
   * @example
   * <TipTap :max-length="500" />
   */
  maxLength?: (number) | null;
  /**
   * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
   */
  enforceMaxLength?: boolean;
  /**
   * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
   * @example
   * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
   */
  bubbleMenuShouldShow?: ((...args: any[]) => any) | null;
  count?: Snippet<[{ characters: any; words: any; maxLength: any; over: any }]>;
  toolbar?: Snippet<[{ editor: any }]>;
  bubbleMenu?: Snippet<[{ editor: any }]>;
  floatingMenu?: Snippet<[{ editor: any }]>;
  linkEditor?: Snippet<[{ editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any }]>;
  nodeView?: Snippet<[{ node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any }]>;
  snippets?: Record<string, any>;
  onupdate?: (...args: unknown[]) => void;
  onselectionupdate?: (...args: unknown[]) => void;
  onfocus?: (...args: unknown[]) => void;
  onblur?: (...args: unknown[]) => void;
}

let __defaultEditorProps = (() => ({}))();
let __defaultExtensions = (() => [])();
let __defaultStarterKit = (() => ({}))();
let __defaultNodeSpecs = (() => [])();

let {
  html = $bindable('<p>Start writing…</p>'),
  editable = true,
  placeholder = '',
  autofocus = false,
  editorClass = '',
  ariaLabel = 'Rich text editor',
  editorProps = __defaultEditorProps,
  extensions = __defaultExtensions,
  starterKit = __defaultStarterKit,
  nodeSpecs = __defaultNodeSpecs,
  uploadImage = null,
  maxLength = null,
  enforceMaxLength = false,
  bubbleMenuShouldShow = null,
  count: __countProp,
  toolbar: __toolbarProp,
  bubbleMenu: __bubbleMenuProp,
  floatingMenu: __floatingMenuProp,
  linkEditor: __linkEditorProp,
  nodeView: __nodeViewProp,
  snippets,
  onupdate,
  onselectionupdate,
  onfocus,
  onblur
}: Props = $props();

const countSlot = $derived(__countProp ?? snippets?.count);
const toolbar = $derived(__toolbarProp ?? snippets?.toolbar);
const bubbleMenu = $derived(__bubbleMenuProp ?? snippets?.bubbleMenu);
const floatingMenu = $derived(__floatingMenuProp ?? snippets?.floatingMenu);
const linkEditor = $derived(__linkEditorProp ?? snippets?.linkEditor);
const nodeView = $derived(__nodeViewProp ?? snippets?.nodeView);

let active = $state({
  bold: false,
  italic: false,
  h1: false,
  h2: false,
  bulletList: false,
  underline: false,
  orderedList: false,
  link: false
});
let count = $state({
  characters: 0,
  words: 0
});
let linkState = $state({
  href: '',
  attrs: {}
});

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

import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';
// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).
let editor: any = null;
// The raw HTML string the editor currently reflects. Compared against in the
// $props.html reconciler so the watcher's mount-time fire is a no-op: the
// editor is created with `content: $props.html`, so right after mount the bound
// model already matches and setContent must NOT re-run (re-running it replaces
// the whole ProseMirror document and resets the selection — the official
// @tiptap/* wrappers guard the same way against the *raw* value, never against
// the normalized `editor.getHTML()`). This is the CodeMirror suppress-echo
// guard in HTML-string form (flatpickr lineage).
let lastHtml: any = null;
// The `toolbar` portal slot's dispose handle. COMPONENT-scope (top-level let),
// NOT a $onMount-local — the Solid emitter hoists the $onMount-returned cleanup
// into a sibling onCleanup() OUTSIDE the mount-body IIFE, so a mount-local would
// lose scope there (the Chart.js tooltipEl/tooltipDispose hoist lesson).
let toolbarDispose: any = null;
// The `bubbleMenu` / `floatingMenu` portal-slot dispose handles + the imperatively
// created menu host elements. COMPONENT-scope for the same hoist reason as
// toolbarDispose — and the host els must be reachable from BOTH the pre-`new
// Editor` extension build (the menu extension needs its `element` at construction)
// AND the post-construction portal mount, so they live here too (not $onMount
// locals). Each stays null when its slot is unfilled (zero overhead, no $portals
// reference fired — the nodeView discipline).
let bubbleMenuEl: any = null;
let bubbleMenuDispose: any = null;
let floatingMenuEl: any = null;
let floatingMenuDispose: any = null;
// ── Link editor (#2) surface. Its OWN dedicated bubble-menu instance (distinct
// `pluginKey: 'rozieLinkEditor'`) with a link-aware trigger, orthogonal to the
// general `bubbleMenu` slot. `linkEditorEl` is the imperatively-created host handed
// to that BubbleMenu extension (the bubbleMenuEl discipline — engine owns
// positioning). COMPONENT-scope for the same hoist reason as the menu els.
//   - When the consumer fills the `#linkEditor` slot → `linkEditorHandle` is the
//     REACTIVE portal handle ({ update, dispose }); refreshLink() re-renders it in
//     place (Spike 016 proved a reactive portal survives the bubble-menu
//     extension's element.remove()/appendChild detach-reattach cycles).
//   - Otherwise → the component builds its OWN default form imperatively into
//     `linkEditorEl` (`linkInputEl` = its URL <input>); refreshLink() imperatively
//     refreshes the input value. Pure-script ⇒ byte-identical across all 6 targets,
//     no framework-reconciliation risk, and no portal default-content (the emitter
//     renders none for an unfilled portal slot).
// `openFlag` = the toolbar Link button's create-mode trigger (set true on click,
// cleared on Apply/Remove/Cancel/blur); the link-aware shouldShow shows the editor
// when `editor.isActive('link')` (edit mode) OR `openFlag` (create mode).
let linkEditorEl: any = null;
let linkEditorHandle: any = null;
let linkInputEl: any = null;
let openFlag = false;
// Last link state refreshLink() reflected, as a compare key — lets refreshLink
// early-return when the link mark is unchanged (a keystroke fires BOTH onUpdate
// and onSelectionUpdate, so refreshLink would otherwise run — and re-render the
// #linkEditor fragment — twice per keystroke).
let lastLinkKey: any = null;
// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
  if (!editor) return;
  active = {
    bold: editor.isActive('bold'),
    italic: editor.isActive('italic'),
    h1: editor.isActive('heading', {
      level: 1
    }),
    h2: editor.isActive('heading', {
      level: 2
    }),
    bulletList: editor.isActive('bulletList'),
    underline: editor.isActive('underline'),
    orderedList: editor.isActive('orderedList'),
    link: editor.isActive('link')
  };
};
// ── Link editor (#2) command helpers + reactive refresh. TOP-LEVEL const arrows
// (siblings of refreshActive/refreshCount) so every `editor` read sits at the same
// shallow, proven-safe depth — never nested inside an object-literal method (the
// redirectNestedThis gap [[project_emitter_redirect_nested_this_gap]]). The link
// scope's setLink/unsetLink/close are these top-level fns, referenced by identity
// from buildLinkScope so the consumer fragment (and the built-in form) call the
// SAME verbs. `extendMarkRange('link')` widens the selection to the whole link so
// an edit/removal applies to the entire mark, not just the caret word.
//
// DECLARATION ORDER IS LOAD-BEARING (topological, leaves first): apply/remove/close
// → buildLinkScope → refreshLink → openLinkEditor. The React/Solid/Lit emitters lift
// reactive closures into useCallback/memo with eager dependency ARRAYS, so a forward
// reference to a later-declared reactive const is a hard TS2448 (use-before-decl) —
// unlike a deferred function BODY, which is fine. apply/removeLink therefore do NOT
// call refreshLink (which would make them depend on it and re-introduce a cycle):
// the setLink/unsetLink chain dispatches a transaction that fires onSelectionUpdate +
// onUpdate, both of which already call refreshLink. Only openLinkEditor (safely last)
// calls it, for immediate prefill on the create affordance.
const applyLink = (attrs: any) => {
  // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
  // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
  if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
  editor?.chain().focus().extendMarkRange('link').setLink(attrs).run();
  openFlag = false;
};
const removeLink = () => {
  editor?.chain().focus().extendMarkRange('link').unsetLink().run();
  openFlag = false;
};
// Force the link-editor BubbleMenu to open/close right now, matching openFlag
// (bug fix — quick 260809-6zp). `editor?.commands.focus()` alone is NOT
// sufficient: TipTap's `focus` command early-returns with NO dispatch whenever
// `view.hasFocus() && position === null` (the whole document is already
// focused — the COMMON case once the create/close affordance is invoked from
// a `@mousedown.prevent`-guarded control, which deliberately never blurs the
// editor). And even a dispatched but otherwise-INERT transaction (no doc/
// selection change) is not enough either: @tiptap/extension-bubble-menu's own
// `update()` short-circuits with `isSame = !selectionChanged && !docChanged`
// BEFORE it ever re-runs `shouldShow` — so a no-op dispatch is silently
// swallowed by the extension's OWN guard, not just TipTap's `focus` command.
// The extension's `transactionHandler` (its own doc comment: "This allows
// external code to trigger ... via `editor.view.dispatch(editor.state.tr
// .setMeta(pluginKey, 'updatePosition'))`") is the official escape hatch: a
// transaction tagged with THIS surface's own `pluginKey` ('rozieLinkEditor')
// calls `show()`/`hide()` directly, bypassing both guards. This bit both
// `openLinkEditor` (create-mode toolbar button) and `closeLink` (built-in
// Cancel AND any consumer `close()`), on every target, whenever the editor
// was already focused. `editor` is the raw TipTap `Editor` instance on all 6.
const forceMenuRecheck = () => {
  if (!editor) return;
  const visible = editor.isEditable && (editor.isActive('link') || openFlag);
  editor.view.dispatch(editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
};
const closeLink = () => {
  openFlag = false;
  // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
  // current link href. The surface itself is link-anchored (like Google Docs) — it
  // stays while the caret is on a link and hides once openFlag is clear and the
  // caret is off any link (or the doc is not editable).
  if (linkInputEl) linkInputEl.value = linkState.href;
  editor?.commands.focus();
  forceMenuRecheck();
};
// The reactive `#linkEditor` slot scope — keys EXACTLY { editor, href, attrs,
// setLink, unsetLink, close } (spec §5.3). `attrs` is the raw link mark attrs
// object so a consumer can read custom attrs (e.g. data-course-link); setLink
// forwards whatever attrs object it is handed VERBATIM (REQ-42 — persistence of a
// custom attr is the consumer's Link.extend concern, not this wrapper's).
//
// Takes `href`/`attrs` as PARAMETERS rather than reading `$data.linkState` —
// every caller has just computed (or is about to compute) these values
// directly from the live editor, and reading them back off `$data`
// immediately after a same-tick write hits the React setState-is-async
// stale-read trap (the D-04 prefill fix's own class of bug, here on the
// ONGOING reactive-refresh path rather than the one-time mount path). Passing
// them straight through keeps every target reading the value that was ACTUALLY
// just computed, not a framework-buffered echo of it.
const buildLinkScope = (href: any, attrs: any) => ({
  editor,
  href,
  attrs,
  setLink: applyLink,
  unsetLink: removeLink,
  close: closeLink
});
// Recompute link state from the live editor + drive the surface. Called from
// onSelectionUpdate + onUpdate (and after content sets). When the consumer slot is
// filled, re-render the reactive portal in place; otherwise refresh the built-in
// form's input value — but NOT while the user is typing in it (don't stomp mid-edit).
const refreshLink = () => {
  if (!editor) return;
  const a = editor.getAttributes('link');
  const href = a.href || '';
  // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
  // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
  // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
  // change the link).
  const key = href + '' + JSON.stringify(a);
  if (key === lastLinkKey) return;
  lastLinkKey = key;
  linkState = {
    href,
    attrs: a
  };
  if (linkEditorHandle) {
    linkEditorHandle.update(buildLinkScope(href, a));
  } else if (linkInputEl && !linkInputEl.matches(':focus')) {
    // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
    // the user typing in this input?" guard holds inside a shadow root — on the Lit
    // target document.activeElement is the shadow HOST, so a document.activeElement
    // check would always miss and stomp the user's in-progress URL. `:focus` is
    // per-element and shadow-boundary-agnostic.
    linkInputEl.value = href;
  }
};
// Toolbar Link button (create affordance, ask C's deferred button): flip the
// open flag so the link-aware shouldShow surfaces the editor on the current
// selection, prefilled with any existing href. Declared AFTER refreshLink so its
// reactive dep array references an already-declared const (see order note above).
export const openLinkEditor = () => {
  openFlag = true;
  editor?.commands.focus();
  refreshLink();
  forceMenuRecheck();
};
// Build the batteries-included default link-editor form imperatively into the
// engine-managed host (the bubble-menu extension owns positioning). Vanilla DOM
// so it is byte-identical across all 6 targets and the framework never reconciles
// it. Enter = Apply, Escape = Cancel. Used ONLY when the `#linkEditor` slot is
// unfilled; a filled slot renders the consumer fragment via the reactive portal.
const buildDefaultLinkEditor = (el: any) => {
  const input = document.createElement('input');
  input.type = 'text';
  input.className = 'rozie-tiptap-link-input';
  input.placeholder = 'https://…';
  const apply = document.createElement('button');
  apply.type = 'button';
  apply.className = 'rozie-tiptap-link-apply';
  apply.textContent = 'Apply';
  const remove = document.createElement('button');
  remove.type = 'button';
  remove.className = 'rozie-tiptap-link-remove';
  remove.textContent = 'Remove';
  const cancel = document.createElement('button');
  cancel.type = 'button';
  cancel.className = 'rozie-tiptap-link-cancel';
  cancel.textContent = 'Cancel';
  // Keep the caret/selection in the document when a control is pressed (a plain
  // click would blur the editor and collapse the selection before the command runs).
  const keepFocus = (e: any) => e.preventDefault();
  for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
  apply.addEventListener('click', () => applyLink({
    href: input.value
  }));
  remove.addEventListener('click', removeLink);
  cancel.addEventListener('click', closeLink);
  input.addEventListener('keydown', (e: any) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      applyLink({
        href: input.value
      });
    } else if (e.key === 'Escape') {
      e.preventDefault();
      closeLink();
    }
  });
  el.appendChild(input);
  el.appendChild(apply);
  el.appendChild(remove);
  el.appendChild(cancel);
  linkInputEl = input;
};
// Recompute the character/word counter from the live editor (D-05). Robust to
// CharacterCount being absent (maxLength unset, no #count slot): reads
// `editor.storage.characterCount` when the extension is registered, else falls
// back to a plain text derivation so `getCharacterCount`/`getWordCount` and the
// #count slot's numbers are never stale.
const refreshCount = () => {
  if (!editor) return;
  const storage = editor.storage.characterCount;
  count = {
    characters: storage ? storage.characters() : editor.getText().length,
    words: storage ? storage.words() : editor.getText().split(/\s+/).filter(Boolean).length
  };
};
// ── StarterKit collision-aware config (ask A). StarterKit bundles several
// node/mark extensions INTERNALLY (invisible to a top-level array dedup) —
// e.g. its own `Link`. A consumer supplying a custom same-named extension via
// `extensions` therefore collides with StarterKit's copy and TipTap warns
// "Duplicate extension names found" while keeping BOTH; only
// `StarterKit.configure({ link:false })` actually disables StarterKit's. This
// map + helper make "consumer wins" true by auto-disabling the StarterKit key
// whenever the consumer supplies a same-named extension AND has not already
// decided that key's fate via the `starterKit` prop. Identity for the 15
// node/mark keys StarterKit exposes as `Partial<Options> | false`, plus the
// undo/redo option key `undoRedo` — mapped from BOTH its actual installed
// `.name` (`'undoRedo'`, verified against `@tiptap/extensions@3.23.5`) and the
// TipTap v2 alias `'history'` as a safety net for a consumer porting a v2
// History extension. Structural/plumbing StarterKit keys (document, text,
// dropcursor, gapcursor, listKeymap, trailingNode) are NOT node/mark
// replacements and are intentionally excluded.
const STARTERKIT_COLLISION_MAP = {
  bold: 'bold',
  italic: 'italic',
  strike: 'strike',
  code: 'code',
  heading: 'heading',
  paragraph: 'paragraph',
  blockquote: 'blockquote',
  codeBlock: 'codeBlock',
  hardBreak: 'hardBreak',
  horizontalRule: 'horizontalRule',
  bulletList: 'bulletList',
  orderedList: 'orderedList',
  listItem: 'listItem',
  link: 'link',
  underline: 'underline',
  undoRedo: 'undoRedo',
  history: 'undoRedo'
};
// Pure helper — returns `userConfig` extended so any StarterKit-bundled
// node/mark the consumer replaced (a same-named entry in `exts`) is disabled
// UNLESS the consumer already decided that key's fate in `userConfig` (an `in`
// presence check, so an explicit `false` OR an explicit options object both
// count as "consumer decided" — D-02, consumer wins unless configured
// explicitly). Never invokes consumer code — only reads `.name` and does key
// presence checks (guards a non-object/missing `.name` entry by skipping it).
const buildStarterKitConfig = (userConfig: any, exts: any) => {
  const effective = {
    ...userConfig
  };
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    if (typeof name !== 'string') continue;
    const optionKey = STARTERKIT_COLLISION_MAP[name];
    if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
  }
  return effective;
};
// Pure helper — D-03 last-wins safety net over the FINAL assembled extension
// array. Dedupes by `.name`, keeping the LAST occurrence (later = consumer).
// A nameless/unnamed entry is never collapsed against another nameless entry
// — each survives, keyed by a per-entry unique fallback rather than a shared
// `undefined` key.
const dedupeExtensionsByName = (exts: any) => {
  const byKey = new Map();
  let anonSeq = 0;
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
    byKey.set(key, ext);
  }
  return [...byKey.values()];
};
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator; generalized in Phase
// 260719-d9e / ask B). When the consumer fills the `nodeView` slot AND
// supplies one or more `nodeSpecs`, each spec becomes its own custom
// ProseMirror node rendering the SAME consumer fragment as a custom node
// *in-engine*, re-rendering it in place on every transaction via the
// reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). The fragment dispatches on `scope.node.type.name` to tell the
// specs apart (D-03 — single-slot-dispatch, no dynamic per-type slot names).
//
// A spec with NO `content` (typically `atom:true`) is a NON-EDITABLE node —
// no contentDOM — driven purely by selectNode/deselectNode/update(node) →
// handle.update so the fragment re-renders in place (engine-driven; no Rozie
// reactive loop). Proven originally by the @mention-chip recipe (Spike 009 /
// REQ-26), now shipped as a `nodeSpecs` entry in the example demos.
//
// A spec WITH `content` (e.g. `'inline*'`) is an EDITABLE BLOCK — it HAS a
// contentDOM. ProseMirror owns the editable hole; the consumer fragment
// renders chrome wrapping a [data-rozie-hole] placeholder and the per-target
// portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here. Proven
// originally by the editable-callout recipe (Spike 008 / REQ-23), now shipped
// as a `nodeSpecs` entry in the example demos.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv: any, spec: any) => (props: any) => {
  const {
    node,
    getPos,
    editor: ed
  } = props;
  // hasContentDOM derives from the spec, not a bare boolean: an editable node
  // is one that is NOT an atom and declares `content` (e.g. 'inline*').
  const hasContentDOM = !spec.atom && !!spec.content;
  // engine-owned outer host the consumer fragment mounts into.
  const dom = document.createElement(hasContentDOM ? 'div' : 'span');
  dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
  // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
  // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
  const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
  if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
  const updateAttributes = (attrs: any) => {
    if (typeof getPos !== 'function') return;
    const pos = getPos();
    if (pos == null) return;
    ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
      ...node.attrs,
      ...attrs
    }));
  };
  const buildScope = (n: any, selected: any) => ({
    node: n,
    selected,
    updateAttributes,
    getPos,
    editor: ed,
    ...(contentDOM ? {
      contentDOM
    } : {})
  });

  // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
  // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
  const handle = nv(dom, buildScope(node, false));

  // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
  // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
  // ProseMirror manages `contentDOM` and renders the node's editable children
  // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
  // rendered into `dom` by the per-target reactive portal — synchronously on
  // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
  // Angular (REQ-23). A query-after-render graft (retried across a microtask +
  // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
  // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
  // and the framework never reconciles it away (the hole carries no child binding).
  const graftContentDOM = (attempt: any) => {
    if (!contentDOM) return;
    const hole = dom.querySelector('[data-rozie-hole]');
    if (hole) {
      if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
      return;
    }
    if (attempt < 5) {
      if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
    }
  };
  graftContentDOM(0);

  // After a reactive re-render (chrome update), re-graft so a fragment that
  // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
  // (REQ-24 — the editable subtree survives every chrome update).
  const updateInPlace = (n: any, selected: any) => {
    handle.update(buildScope(n, selected));
    if (contentDOM) graftContentDOM(0);
  };
  return {
    dom,
    ...(contentDOM ? {
      contentDOM
    } : {}),
    // attr / content change for THIS node → re-render the fragment in place,
    // keep the view (return true). The new node identity is forwarded so the
    // fragment reads fresh node.attrs (REQ-26).
    update(nextNode: any) {
      if (nextNode.type !== node.type) return false;
      updateInPlace(nextNode, false);
      return true;
    },
    // NodeSelection enters/leaves the node → toggle `selected` in scope so the
    // chip's selected styling is pure engine-driven reactive `update`.
    selectNode() {
      updateInPlace(node, true);
    },
    deselectNode() {
      updateInPlace(node, false);
    },
    destroy() {
      handle.dispose();
    }
  };
};
// Pure helper (ask B, D-02) — extracts { el, attr, value } from a parseHTML
// tag selector string, e.g. 'span[data-x]' → { el: 'span', attr: 'data-x',
// value: '' } or 'div[data-x=y]' → { el: 'div', attr: 'data-x', value: 'y' }.
// Drives renderHTML's marker attribute so the serialized element reproduces
// the exact shape the parseHTML rule expects. MUST NOT throw on a
// malformed/empty selector (T-d9e-01 — a bad selector degrades only that one
// node's render, never crashes the editor): falls back to el = the raw
// selector (or 'span' if falsy), attr = null (no marker), value = ''.
const parseTagSelector = (selector: any) => {
  const raw = typeof selector === 'string' ? selector : '';
  const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
  const el = elMatch ? elMatch[0] : raw || 'span';
  const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
  if (!attrMatch) return {
    el,
    attr: null,
    value: ''
  };
  const attr = (attrMatch[1] ?? '').trim();
  const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
  return {
    el,
    attr,
    value
  };
};
// Build ONE custom Node per consumer-supplied spec, all bound to the SAME
// reactive nodeView portal (ask B, D-02). Takes the per-target
// `$portals.nodeView` (captured here so the reference stays inside the mount
// lifecycle — never top-level, per the bundled-leaf typecheck rule) and the
// `nodeSpecs` prop array (read once at mount — setup-once, not reactive).
const makeNodeViewExtensions = (nv: any, specs: any) => specs.map((spec: any) => {
  // hasContentDOM decides the renderHTML hole: an editable (non-atom,
  // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
  // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
  // a leaf node spec" otherwise).
  const hasContentDOM = !spec.atom && !!spec.content;
  const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
  const {
    el,
    attr,
    value
  } = parseTagSelector(firstTag);
  return Node.create({
    name: spec.name,
    group: spec.group ?? 'block',
    inline: spec.inline ?? false,
    atom: spec.atom ?? false,
    selectable: spec.selectable ?? true,
    defining: spec.defining ?? false,
    ...(spec.content ? {
      content: spec.content
    } : {}),
    addAttributes: () => spec.attrs ?? {},
    parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
      tag: t
    })),
    renderHTML: ({
      HTMLAttributes
    }: any) => hasContentDOM ? [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }, 0] : [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }],
    addNodeView: () => makeNodeView(nv, spec)
  });
});
// Shared image-file finder for the upload handlers below — the first
// `image/*` File in a FileList, else undefined. Guards a missing FileList.
const findImageFile = (files: any) => {
  if (!files) return undefined;
  for (let i = 0; i < files.length; i++) {
    const f = files[i];
    if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
  }
  return undefined;
};
// uploadImage paste/drop fallbacks (ask D / D-04) — ProseMirror `editorProps`
// handlers. TOP-LEVEL functions (siblings of `refreshActive`/the $expose
// verbs below), NOT nested inside $onMount's ternary/object-literal — a
// closure reading the component-scope `editor` from several function-levels
// deep inside $onMount (object-literal method → `.then` callback) hits a
// `this`-rebinding gap on the class-based targets (Angular/Lit) that the
// emitter's nested-`this` repair does not reach at that depth
// (emitter-backlog). A top-level function is only ONE level removed from the
// promoted-`this` boundary — the same shallow depth as the `onUpdate` /
// `$watch` callbacks elsewhere in this file, which already compile clean —
// so referencing `editor` here needs no repair at all. Each handler claims
// ONLY an image/* payload: returns `true` SYNCHRONOUSLY (claiming the
// paste/drop now — never awaits inside the handler) and inserts the resolved
// URL once the consumer's uploadImage promise settles; a rejection is
// swallowed (`.catch(() => {})`) so a failed upload never crashes the editor
// (T-e7i-01). Returns `false` for a non-image payload — or, for drop, an
// internal node move — so ProseMirror (or a consumer editorProps handler,
// which still wins via the LAST spread) processes it normally.
function handlePaste(view: any, event: any, slice: any) {
  // Captured into a local (not repeated `$props.uploadImage` member reads) so
  // the null-check narrows the type on every target — including Lit, where
  // the Function prop lowers to a nullable function type and a bare
  // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
  // typecheck (TS2721) even though this handler is only ever wired into
  // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
  // gate already guarantees this in practice).
  const upload = uploadImage;
  if (!upload) return false;
  const file = findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
  if (!file) return false;
  event.preventDefault();
  upload(file).then((url: any) => {
    editor?.chain().focus().setImage({
      src: url
    }).run();
  }).catch(() => {});
  return true;
}
function handleDrop(view: any, event: any, slice: any, moved: any) {
  if (moved) return false;
  // See handlePaste — local capture for the same cross-target null-narrowing.
  const upload = uploadImage;
  if (!upload) return false;
  const file = findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
  if (!file) return false;
  event.preventDefault();
  const pos = view.posAtCoords({
    left: event.clientX,
    top: event.clientY
  });
  upload(file).then((url: any) => {
    const insertPos = pos ? pos.pos : editor ? editor.state.selection.head : 0;
    editor?.chain().focus().insertContentAt(insertPos, {
      type: 'image',
      attrs: {
        src: url
      }
    }).run();
  }).catch(() => {});
  return true;
}
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 25 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
//   - The content setter is named `setContent`, NOT `setHtml` — an `html` model
//     prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
//     $expose verb would collide on the React target (ROZ524). (CodeMirror's
//     setValue→replaceValue lesson, html edition.)
//   - None of the 25 names collide with LitElement reserved lifecycle methods
//     (update/render/firstUpdated/updated/willUpdate/requestUpdate).
//   - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
//     `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
//     class-based targets (Angular) an output field and a method cannot share a
//     name (ROZ121). The diagnostic's own guidance: rename the method, keep the
//     event's public name. (The expose-verb-vs-event-name collision lesson.)
//   - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
//     ariaLabel/editorProps/extensions).
export function getEditor() {
  return editor;
}
export function focusEditor() {
  editor?.commands.focus();
}
export function blurEditor() {
  editor?.commands.blur();
}
export function getHTML() {
  return editor ? editor.getHTML() : '';
}
export function getJSON() {
  return editor ? editor.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
export function getText() {
  return editor ? editor.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
export function setContent(next: any) {
  if (!editor) return;
  const v = next ?? '';
  if (v === lastHtml) return;
  lastHtml = v;
  editor.commands.setContent(v, {
    emitUpdate: false
  });
  html = v;
  refreshActive();
  refreshCount();
  refreshLink();
}
export function clearContent() {
  if (!editor) return;
  editor.commands.clearContent();
  lastHtml = editor.getHTML();
  html = lastHtml;
  refreshActive();
  refreshCount();
  refreshLink();
}
export function toggleBold() {
  editor?.chain().focus().toggleBold().run();
  refreshActive();
}
export function toggleItalic() {
  editor?.chain().focus().toggleItalic().run();
  refreshActive();
}
export function toggleHeading(level: any) {
  editor?.chain().focus().toggleHeading({
    level: level ?? 1
  }).run();
  refreshActive();
}
export function toggleBulletList() {
  editor?.chain().focus().toggleBulletList().run();
  refreshActive();
}
export function toggleUnderline() {
  editor?.chain().focus().toggleUnderline().run();
  refreshActive();
}
export function toggleOrderedList() {
  editor?.chain().focus().toggleOrderedList().run();
  refreshActive();
}
export function undo() {
  editor?.chain().focus().undo().run();
  refreshActive();
}
export function redo() {
  editor?.chain().focus().redo().run();
  refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
export function chain() {
  return editor ? editor.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
//   - isActive(name, attrs?): is a mark/node active in the current selection
//     (drive toolbar button active styling). False before mount.
//   - can(): the command-availability chain (editor.can().chain()…run()) for
//     enable/disable of toolbar buttons. null before mount (mirrors chain()).
//   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
export function isActive(name: any, attrs: any) {
  return editor ? editor.isActive(name, attrs) : false;
}
export function can() {
  return editor ? editor.can() : null;
}
export function isEmpty() {
  return editor ? editor.isEmpty : true;
}
// Character/word count reads (D-04). Prefer the CharacterCount extension's live
// storage when registered (maxLength set or #count slot filled); otherwise a
// text-based fallback so these ALWAYS return a number — 0 before mount, and a
// correct count even on a stock <TipTap> that never registered CharacterCount.
export function getCharacterCount() {
  if (!editor) return 0;
  return editor.storage.characterCount ? editor.storage.characterCount.characters() : editor.getText().length;
}
export function getWordCount() {
  if (!editor) return 0;
  return editor.storage.characterCount ? editor.storage.characterCount.words() : editor.getText().split(/\s+/).filter(Boolean).length;
}
// setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
// applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
// (buildLinkScope above), so the imperative handle and the slot-scope verb
// implementation cannot disagree. Four-way collision check:
//   - not a prop name — the 14 props are html / editable / placeholder /
//     autofocus / editorClass / ariaLabel / editorProps / extensions /
//     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
//     bubbleMenuShouldShow;
//   - not an emitted event name — the 4 events are update / selectionUpdate /
//     focus / blur (the ROZ121 Angular output-field-vs-method rule);
//   - not an existing $expose verb — the 23 names already in the object below;
//   - not a React auto-generated model setter — the only model prop is
//     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
//     `setContent`), and not a LitElement lifecycle method (update / render /
//     firstUpdated / updated / willUpdate / requestUpdate).
// applyLink already ignores an attrs object without a non-empty string href
// (no degenerate empty-href anchor is ever written), and both verbs no-op
// before mount / after destroy through the `editor?.` guards already inside
// applyLink/removeLink — no second validation path is introduced.
export function setLink(attrs: any) {
  applyLink(attrs);
}
export function unsetLink() {
  removeLink();
}

interface ReactivePortalHandle {
  update(scope: unknown): void;
  dispose(): void;
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    if (!toolbar) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: toolbar, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    if (!bubbleMenu) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: bubbleMenu, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
    if (!floatingMenu) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: floatingMenu, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
  linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
    if (!linkEditor) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
    const inst = mount(PortalHostReactive, {
      target: container,
      props: { snippet: linkEditor, 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>);
      },
    };
  },
  nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
    if (!nodeView) return { update() {}, dispose() {} };
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
    const inst = mount(PortalHostReactive, {
      target: container,
      props: { snippet: nodeView, 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(() => {
  lastHtml = html;

  // Register the reactive node-view nodes ONLY when the consumer fills the
  // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
  // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
  // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
  // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
  // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
  // here inside the mount body and passed into the node factory, keeping the
  // reference scoped to the mount lifecycle (the toolbar-slot discipline).
  const nodeViewExtensions = nodeView && nodeSpecs.length ? makeNodeViewExtensions(portals.nodeView, nodeSpecs) : [];

  // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
  // (setup-once, like content/editable/autofocus — no reactivity required). The
  // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
  // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
  // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
  // (in the style block) paints the ghost text. Empty placeholder = no extension.
  const placeholderExtensions = placeholder ? [Placeholder.configure({
    placeholder: placeholder
  })] : [];

  // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
  // Floating-UI menu extension needs its host `element` at construction time. Each
  // menu's host element is created imperatively (the nodeView discipline — the
  // engine owns positioning; the consumer fragment is portalled in AFTER mount).
  // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
  //
  // The host elements are created up front (when filled) so they're captured into
  // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
  // portal mount; the extension list is then assembled by conditional SPREAD (NOT
  // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
  // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
  // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
  if (bubbleMenu) {
    bubbleMenuEl = document.createElement('div');
    bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
  }
  if (floatingMenu) {
    floatingMenuEl = document.createElement('div');
    floatingMenuEl.className = 'rozie-tiptap-floating-menu';
  }
  // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
  // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
  // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
  // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
  // the link-editor shouldShow below — a live check that follows a runtime toggle.
  // This closes both directions of the mount-time-gate bug: a doc mounted readonly
  // that later becomes editable gets a working link editor, and a doc toggled TO
  // readonly can no longer be link-edited (isEditable false → never shows, so no
  // Apply/Remove on a read-only document).
  linkEditorEl = document.createElement('div');
  linkEditorEl.className = 'rozie-tiptap-link-editor';
  // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
  // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
  // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
  // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
  // (non-empty text selection). The link editor's shouldShow is link-aware: show
  // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
  // by design so it never fires on a bare selection and collide with the general one.
  const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
    pluginKey: 'rozieBubbleMenu',
    element: bubbleMenuEl,
    ...(bubbleMenuShouldShow ? {
      shouldShow: bubbleMenuShouldShow
    } : {})
  })] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
    element: floatingMenuEl
  })] : []), ...(linkEditorEl ? [BubbleMenu.configure({
    pluginKey: 'rozieLinkEditor',
    element: linkEditorEl,
    // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
    // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
    // button set openFlag (create) — never on a bare selection.
    shouldShow: ({
      editor
    }: any) => editor.isEditable && (editor.isActive('link') || openFlag)
  })] : [])];

  // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
  // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
  // absent: no Image extension, no paste/drop handlers (zero overhead, the
  // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
  // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
  const imageExtensions = uploadImage ? [Image] : [];

  // Character/word count (D-01..D-03). Gated on maxLength being set OR the
  // `count` slot being filled — a stock <TipTap> with neither registers NO
  // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
  // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
  // cap); otherwise CharacterCount tracks with no limit (soft — overflow
  // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
  // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
  // never[]-inference reason as placeholderExtensions/imageExtensions.
  const needsCount = maxLength != null || countSlot;
  const characterCountExtensions = needsCount ? [CharacterCount.configure(enforceMaxLength && maxLength != null ? {
    limit: maxLength
  } : {})] : [];

  // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
  // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
  // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
  // functions declared above (see their doc comment for why they live at the
  // top level rather than as closures nested in this ternary).
  const uploadHandlers = uploadImage ? {
    handlePaste,
    handleDrop
  } : {};
  editor = new Editor({
    element: editorEl!,
    content: html,
    editable: editable,
    autofocus: autofocus,
    // StarterKit first (config-disabled per the collision scan below); the
    // Placeholder ext next; the reactive node-view nodes next; consumer
    // extensions LAST so they win (TipTap applies later-registered extensions
    // over earlier ones for the same node/mark) — and the whole array is
    // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
    // of the config-level auto-disable (D-02), which is what actually silences
    // StarterKit's internal same-named extension (e.g. its bundled `Link`).
    extensions: dedupeExtensionsByName([StarterKit.configure(buildStarterKitConfig(starterKit, extensions)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ...extensions]),
    editorProps: {
      attributes: {
        'aria-label': ariaLabel,
        ...(editorClass ? {
          class: editorClass
        } : {}),
        ...(placeholder ? {
          'data-placeholder': placeholder,
          'aria-placeholder': placeholder
        } : {})
      },
      // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
      // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
      // `{}` (no-op) when $props.uploadImage is unset.
      ...uploadHandlers,
      // Consumer editorProps spread LAST — full ProseMirror editorProps control
      // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
      ...editorProps
    },
    onUpdate: ({
      editor
    }: any) => {
      const next = editor.getHTML();
      lastHtml = next;
      // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
      if (next !== html) html = next;
      refreshCount();
      refreshLink();
      onupdate?.(next);
    },
    onSelectionUpdate: () => {
      refreshActive();
      refreshLink();
      onselectionupdate?.();
    },
    onFocus: () => onfocus?.(),
    onBlur: ({
      event
    }: any) => {
      // Clear the create-mode latch when focus truly leaves the editor + its link
      // surface — but NOT when it moves INTO the link editor host (clicking the URL
      // input blurs the editor; the buttons are already covered by their keepFocus
      // mousedown). Without this, openFlag stays true after the user dismisses the
      // create affordance by clicking away, so the editor spuriously re-surfaces on
      // the next unrelated selection.
      const to = event && event.relatedTarget;
      if (!(to instanceof Node && linkEditorEl && linkEditorEl.contains(to))) openFlag = false;
      onblur?.();
    }
  });
  refreshActive();
  refreshCount();
  refreshLink();

  // `toolbar` portal slot — when the consumer fills it, mount their toolbar
  // fragment into the engine-adjacent host node, handing them the live editor
  // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
  // referenced ONLY here inside $onMount (the per-target portal helper is scoped
  // to the mount lifecycle — a top-level reference would fail the bundled-leaf
  // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
  // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
  if (toolbar && toolbarEl) {
    toolbarDispose = portals.toolbar(toolbarEl!, {
      editor
    });
  }

  // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
  // fragment into the engine-owned (imperatively-created) host element handed to
  // the Floating-UI menu extension, with the live editor in scope (their buttons
  // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
  // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
  // strict-typecheck discipline). The element is created above only when the slot
  // is filled, so each portal fires exactly when its slot exists.
  if (bubbleMenuEl) {
    bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
      editor
    });
  }
  if (floatingMenuEl) {
    floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
      editor
    });
  }

  // Link editor (#2) — mount the surface into its engine-managed host. When the
  // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
  // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
  // this survives the bubble-menu extension's detach-reattach). Otherwise the
  // component's own default form is built imperatively into the same host.
  // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
  if (linkEditorEl) {
    if (linkEditor) {
      // Read the initial link attrs straight off the live editor (NOT
      // `$data.linkState`, written by the refreshLink() call above in this
      // same tick) — the same React stale-read avoidance as buildLinkScope's
      // other call site.
      const initialLinkAttrs = editor.getAttributes('link');
      linkEditorHandle = portals.linkEditor(linkEditorEl, buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs));
    } else {
      buildDefaultLinkEditor(linkEditorEl);
      // Prefill correction (D-04): the refreshLink() call above (right after
      // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
      // exist yet at that point, so every LATER refreshLink() for the same
      // link early-returns, leaving the just-created input empty even when the
      // caret starts inside a link. Seed it directly from the LIVE editor
      // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
      // $data key immediately after refreshLink() just wrote it hits the
      // React setState-is-async stale-read trap (the same write-then-read-in-
      // one-handler class ROZ138 warns about elsewhere in this file), since
      // $data.linkState was written by the refreshLink() call directly above.
      // `editor` is a plain instance handle, not reactive state, so reading it
      // straight off the engine is synchronous and target-uniform. A no-link
      // mount leaves this the empty string (unchanged).
      if (linkInputEl) linkInputEl.value = editor.getAttributes('link').href || '';
    }
  }
  return () => {
    toolbarDispose?.();
    toolbarDispose = null;
    bubbleMenuDispose?.();
    bubbleMenuDispose = null;
    floatingMenuDispose?.();
    floatingMenuDispose = null;
    linkEditorHandle?.dispose();
    linkEditorHandle = null;
    linkEditorEl = null;
    linkInputEl = null;
    editor?.destroy();
  };
});

let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => html)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
  if (!editor) return;
  if (v === lastHtml) return;
  lastHtml = v;
  editor.commands.setContent(v, {
    emitUpdate: false
  });
  refreshActive();
  refreshCount();
  refreshLink();
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => editable)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => editor?.setEditable(v, false))(__watchVal); }); });
</script>

<div class={["rozie-tiptap", { 'is-readonly': !editable }]} data-rozie-s-2aeee876>{#if editable && !toolbar}<div class="rozie-tiptap-toolbar" data-rozie-s-2aeee876><button type="button" class={{ active: active.bold }} aria-label="Bold" onclick={toggleBold} data-rozie-s-2aeee876><strong data-rozie-s-2aeee876>B</strong></button><button type="button" class={{ active: active.italic }} aria-label="Italic" onclick={toggleItalic} data-rozie-s-2aeee876><em data-rozie-s-2aeee876>I</em></button><span class="sep" data-rozie-s-2aeee876></span><button type="button" class={{ active: active.h1 }} aria-label="Heading 1" onclick={($event) => { toggleHeading(1); }} data-rozie-s-2aeee876>H1</button><button type="button" class={{ active: active.h2 }} aria-label="Heading 2" onclick={($event) => { toggleHeading(2); }} data-rozie-s-2aeee876>H2</button><span class="sep" data-rozie-s-2aeee876></span><button type="button" class={{ active: active.bulletList }} aria-label="Bullet list" onclick={toggleBulletList} data-rozie-s-2aeee876>• List</button><button type="button" class={{ active: active.underline }} aria-label="Underline" onclick={toggleUnderline} data-rozie-s-2aeee876><u data-rozie-s-2aeee876>U</u></button><button type="button" class={{ active: active.orderedList }} aria-label="Ordered list" onclick={toggleOrderedList} data-rozie-s-2aeee876>1. List</button><span class="sep" data-rozie-s-2aeee876></span><button type="button" class={{ active: active.link }} aria-label="Link" onclick={openLinkEditor} data-rozie-s-2aeee876>Link</button><span class="sep" data-rozie-s-2aeee876></span><button type="button" aria-label="Undo" onclick={undo} data-rozie-s-2aeee876>↺</button><button type="button" aria-label="Redo" onclick={redo} data-rozie-s-2aeee876>↻</button></div>{/if}{#if editable && toolbar}<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" bind:this={toolbarEl} data-rozie-s-2aeee876></div>{/if}<div bind:this={editorEl} class="rozie-tiptap-content" data-placeholder={placeholder} data-rozie-s-2aeee876></div>{#if maxLength != null || countSlot}<div class="rozie-tiptap-count" data-rozie-s-2aeee876>{#if countSlot}{@render countSlot({ characters: count.characters, words: count.words, maxLength, over: maxLength != null && count.characters > maxLength })}{:else}<span class={["rozie-tiptap-count-value", { over: maxLength != null && count.characters > maxLength }]} data-rozie-s-2aeee876>{rozieDisplay(count.characters)} / {maxLength}</span>{/if}</div>{/if}</div>

<style>
:global {
  .rozie-tiptap[data-rozie-s-2aeee876] {
    border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
    border-radius: var(--rozie-tiptap-radius, 6px);
    overflow: hidden;
    background: var(--rozie-tiptap-bg, white);
  }
  .rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
    background: var(--rozie-tiptap-readonly-bg, #fafafa);
  }
  .rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
    padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
    border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
    background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
  }
  .rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
    padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
    border: var(--rozie-tiptap-button-border, 1px solid transparent);
    background: var(--rozie-tiptap-button-bg, transparent);
    border-radius: var(--rozie-tiptap-button-radius, 3px);
    cursor: pointer;
    font: inherit;
    font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
    min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
    color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
  }
  .rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
    background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
  }
  .rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
    background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
    color: var(--rozie-tiptap-button-active-color, white);
    border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
  }
  .rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
    width: var(--rozie-tiptap-toolbar-sep-width, 1px);
    height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
    background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
    margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
  }
  .rozie-tiptap-content[data-rozie-s-2aeee876] {
    padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
    min-height: var(--rozie-tiptap-content-min-height, 6rem);
    font: inherit;
    outline: none;
  }
  .rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
  .rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
  .rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
  .rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
  .rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }
  .rozie-tiptap-count[data-rozie-s-2aeee876] {
    display: flex;
    justify-content: flex-end;
    padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
    border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
    font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
    color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
  }
  .rozie-tiptap-count-value.over[data-rozie-s-2aeee876] {
    color: var(--rozie-tiptap-count-over-color, #c0392b);
  }
}

:global {
  .rozie-tiptap-content .is-editor-empty:first-child::before {
      content: attr(data-placeholder);
      color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
      float: left;
      height: 0;
      pointer-events: none;
    }
  .rozie-tiptap-link-editor {
      display: flex;
      align-items: center;
      gap: var(--rozie-tiptap-link-gap, 0.25rem);
      padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
      background: var(--rozie-tiptap-link-bg, #1a1a1a);
      border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
      border-radius: var(--rozie-tiptap-link-radius, 6px);
      box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
    }
  .rozie-tiptap-link-input {
      font: inherit;
      font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
      padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
      min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
      border: var(--rozie-tiptap-link-input-border, 1px solid #444);
      border-radius: var(--rozie-tiptap-link-input-radius, 4px);
      background: var(--rozie-tiptap-link-input-bg, #fff);
      color: var(--rozie-tiptap-link-input-color, #000);
    }
  .rozie-tiptap-link-editor button {
      font: inherit;
      font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
      padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
      border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
      border-radius: var(--rozie-tiptap-link-button-radius, 4px);
      background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
      color: var(--rozie-tiptap-link-button-color, #fff);
      cursor: pointer;
    }
  .rozie-tiptap-link-editor button:hover {
      background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
    }
  .rozie-tiptap-link-editor .rozie-tiptap-link-remove {
      color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
    }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, computed, contentChild, contentChildren, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { RozieSlot, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';

// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).

interface CountCtx {
  $implicit: { characters: any; words: any; maxLength: any; over: any };
  characters: any;
  words: any;
  maxLength: any;
  over: any;
}

interface ToolbarCtx {
  $implicit: { editor: any };
  editor: any;
}

interface BubbleMenuCtx {
  $implicit: { editor: any };
  editor: any;
}

interface FloatingMenuCtx {
  $implicit: { editor: any };
  editor: any;
}

interface LinkEditorCtx {
  $implicit: { editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any };
  editor: any;
  href: any;
  attrs: any;
  setLink: any;
  unsetLink: any;
  close: any;
}

interface NodeViewCtx {
  $implicit: { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any };
  node: any;
  selected: any;
  updateAttributes: any;
  getPos: any;
  editor: any;
  contentDOM: any;
}

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

    <div class="rozie-tiptap" [ngClass]="{ 'is-readonly': !editable() }">
      
      @if (editable() && !(toolbarTpl ?? __rozieFillMap()['toolbar'] ?? templates()?.['toolbar'])) {
    <div class="rozie-tiptap-toolbar">
        <button type="button" [class]="{ active: active().bold }" aria-label="Bold" (click)="toggleBold()"><strong>B</strong></button>
        <button type="button" [class]="{ active: active().italic }" aria-label="Italic" (click)="toggleItalic()"><em>I</em></button>
        <span class="sep"></span>
        <button type="button" [class]="{ active: active().h1 }" aria-label="Heading 1" (click)="toggleHeading(1)">H1</button>
        <button type="button" [class]="{ active: active().h2 }" aria-label="Heading 2" (click)="toggleHeading(2)">H2</button>
        <span class="sep"></span>
        <button type="button" [class]="{ active: active().bulletList }" aria-label="Bullet list" (click)="toggleBulletList()">• List</button>
        <button type="button" [class]="{ active: active().underline }" aria-label="Underline" (click)="toggleUnderline()"><u>U</u></button>
        <button type="button" [class]="{ active: active().orderedList }" aria-label="Ordered list" (click)="toggleOrderedList()">1. List</button>
        <span class="sep"></span>
        <button type="button" [class]="{ active: active().link }" aria-label="Link" (click)="openLinkEditor()">Link</button>
        <span class="sep"></span>
        <button type="button" aria-label="Undo" (click)="undo()">↺</button>
        <button type="button" aria-label="Redo" (click)="redo()">↻</button>
      </div>
    }@if (editable() && (toolbarTpl ?? __rozieFillMap()['toolbar'] ?? templates()?.['toolbar'])) {
    <div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" #toolbarEl></div>
    }<div #editorEl class="rozie-tiptap-content" [attr.data-placeholder]="placeholder()"></div>
      
      @if (maxLength() != null || (countTpl ?? __rozieFillMap()['count'] ?? templates()?.['count'])) {
    <div class="rozie-tiptap-count">
        @if ((countTpl ?? __rozieFillMap()['count'] ?? templates()?.['count'])) {
    <ng-container *ngTemplateOutlet="(countTpl ?? __rozieFillMap()['count'] ?? templates()?.['count']); context: { $implicit: { characters: count().characters, words: count().words, maxLength: maxLength(), over: maxLength() != null && count().characters > maxLength() }, characters: count().characters, words: count().words, maxLength: maxLength(), over: maxLength() != null && count().characters > maxLength() }" />
    } @else {

          <span class="rozie-tiptap-count-value" [ngClass]="{ over: maxLength() != null && count().characters > maxLength() }">{{ rozieDisplay(count().characters) }} / {{ maxLength() }}</span>
        
    }
      </div>
    }</div>









    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    :host(rozie-tip-tap) { display: contents; }
    .rozie-tiptap {
      border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
      border-radius: var(--rozie-tiptap-radius, 6px);
      overflow: hidden;
      background: var(--rozie-tiptap-bg, white);
    }
    .rozie-tiptap.is-readonly {
      background: var(--rozie-tiptap-readonly-bg, #fafafa);
    }
    .rozie-tiptap-toolbar {
      display: flex;
      align-items: center;
      gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
      padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
      border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
      background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
    }
    .rozie-tiptap-toolbar button {
      padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
      border: var(--rozie-tiptap-button-border, 1px solid transparent);
      background: var(--rozie-tiptap-button-bg, transparent);
      border-radius: var(--rozie-tiptap-button-radius, 3px);
      cursor: pointer;
      font: inherit;
      font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
      min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
      color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
    }
    .rozie-tiptap-toolbar button:hover {
      background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
    }
    .rozie-tiptap-toolbar button.active {
      background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
      color: var(--rozie-tiptap-button-active-color, white);
      border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
    }
    .rozie-tiptap-toolbar .sep {
      width: var(--rozie-tiptap-toolbar-sep-width, 1px);
      height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
      background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
      margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
    }
    .rozie-tiptap-content {
      padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
      min-height: var(--rozie-tiptap-content-min-height, 6rem);
      font: inherit;
      outline: none;
    }
    .rozie-tiptap-content p { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
    .rozie-tiptap-content p:last-child { margin-bottom: 0; }
    .rozie-tiptap-content h1 { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
    .rozie-tiptap-content h2 { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
    .rozie-tiptap-content ul { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }
    .rozie-tiptap-count {
      display: flex;
      justify-content: flex-end;
      padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
      border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
      font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
      color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
    }
    .rozie-tiptap-count-value.over {
      color: var(--rozie-tiptap-count-over-color, #c0392b);
    }

    ::ng-deep .rozie-tiptap-content .is-editor-empty:first-child::before {
        content: attr(data-placeholder);
        color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
        float: left;
        height: 0;
        pointer-events: none;
      }
    ::ng-deep .rozie-tiptap-link-editor {
        display: flex;
        align-items: center;
        gap: var(--rozie-tiptap-link-gap, 0.25rem);
        padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
        background: var(--rozie-tiptap-link-bg, #1a1a1a);
        border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
        border-radius: var(--rozie-tiptap-link-radius, 6px);
        box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
      }
    ::ng-deep .rozie-tiptap-link-input {
        font: inherit;
        font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
        padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
        min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
        border: var(--rozie-tiptap-link-input-border, 1px solid #444);
        border-radius: var(--rozie-tiptap-link-input-radius, 4px);
        background: var(--rozie-tiptap-link-input-bg, #fff);
        color: var(--rozie-tiptap-link-input-color, #000);
      }
    ::ng-deep .rozie-tiptap-link-editor button {
        font: inherit;
        font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
        padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
        border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
        border-radius: var(--rozie-tiptap-link-button-radius, 4px);
        background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
        color: var(--rozie-tiptap-link-button-color, #fff);
        cursor: pointer;
      }
    ::ng-deep .rozie-tiptap-link-editor button:hover {
        background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
      }
    ::ng-deep .rozie-tiptap-link-editor .rozie-tiptap-link-remove {
        color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
      }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TipTap),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class TipTap {
  /**
   * The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
   * @example
   * <TipTap r-model:html="content" placeholder="Start writing…" />
   */
  html = model<string>('<p>Start writing…</p>');
  /**
   * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
   */
  editable = input<boolean>(true);
  /**
   * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
   */
  placeholder = input<string>('');
  /**
   * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
   */
  autofocus = input<boolean>(false);
  /**
   * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
   */
  editorClass = input<string>('');
  /**
   * The accessible name (`aria-label`) applied to the contenteditable element.
   */
  ariaLabel = input<string>('Rich text editor');
  /**
   * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
   */
  editorProps = input<Record<string, any>>((() => ({}))());
  /**
   * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
   */
  extensions = input<any[]>((() => [])());
  /**
   * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
   */
  starterKit = input<Record<string, any>>((() => ({}))());
  /**
   * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
   * @example
   * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
   */
  nodeSpecs = input<any[]>((() => [])());
  /**
   * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
   * @example
   * <TipTap :upload-image="uploadFn" />
   */
  uploadImage = input<((...args: any[]) => any) | null>(null);
  /**
   * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
   * @example
   * <TipTap :max-length="500" />
   */
  maxLength = input<(number) | null>(null);
  /**
   * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
   */
  enforceMaxLength = input<boolean>(false);
  /**
   * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
   * @example
   * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
   */
  bubbleMenuShouldShow = input<((...args: any[]) => any) | null>(null);
  active = signal({
    bold: false,
    italic: false,
    h1: false,
    h2: false,
    bulletList: false,
    underline: false,
    orderedList: false,
    link: false
  });
  count = signal({
    characters: 0,
    words: 0
  });
  linkState = signal({
    href: '',
    attrs: {}
  });
  toolbarEl = viewChild<ElementRef<HTMLDivElement>>('toolbarEl');
  editorEl = viewChild<ElementRef<HTMLDivElement>>('editorEl');
  update = output<unknown>();
  selectionUpdate = output<void>();
  focus = output<void>();
  blur = output<void>();
  @ContentChild('count', { read: TemplateRef }) countTpl?: TemplateRef<CountCtx>;
  @ContentChild('toolbar', { read: TemplateRef }) toolbarTpl?: TemplateRef<ToolbarCtx>;
  @ContentChild('bubbleMenu', { read: TemplateRef }) bubbleMenuTpl?: TemplateRef<BubbleMenuCtx>;
  @ContentChild('floatingMenu', { read: TemplateRef }) floatingMenuTpl?: TemplateRef<FloatingMenuCtx>;
  @ContentChild('linkEditor', { read: TemplateRef }) linkEditorTpl?: TemplateRef<LinkEditorCtx>;
  @ContentChild('nodeView', { read: TemplateRef }) nodeViewTpl?: TemplateRef<NodeViewCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _toolbarTpl = contentChild('toolbar', { read: TemplateRef });
  private _bubbleMenuTpl = contentChild('bubbleMenu', { read: TemplateRef });
  private _floatingMenuTpl = contentChild('floatingMenu', { read: TemplateRef });
  private _linkEditorTpl = contentChild('linkEditor', { read: TemplateRef });
  private _nodeViewTpl = contentChild('nodeView', { read: TemplateRef });
  private __rozieDestroyRef = inject(DestroyRef);
  private __rozieWatchInitial_0 = true;
  private __rozieWatchInitial_1 = true;

  constructor() {
    effect(() => { const __watchVal = (() => this.html())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
      if (!this.editor) return;
      if (v === this.lastHtml) return;
      this.lastHtml = v;
      this.editor.commands.setContent(v, {
        emitUpdate: false
      });
      this.refreshActive();
      this.refreshCount();
      this.refreshLink();
    })(__watchVal); }); });
    effect(() => { const __watchVal = (() => this.editable())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => this.editor?.setEditable(v, false))(__watchVal); }); });
  }

  ngAfterViewInit() {
    interface ReactivePortalHandle {
      update(scope: unknown): void;
      dispose(): void;
    }
    const portals = {
      toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this._toolbarTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
        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>);
        };
      },
      bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this._bubbleMenuTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
        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>);
        };
      },
      floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this._floatingMenuTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
        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>);
        };
      },
      linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
        const tpl = this._linkEditorTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
        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>);
          },
        };
      },
      nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
        const tpl = this._nodeViewTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
        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>);
          },
        };
      },
    };
    const __nodeSpecs = this.nodeSpecs();
    const __placeholder = this.placeholder();
    const __bubbleMenuShouldShow = this.bubbleMenuShouldShow();
    const __uploadImage = this.uploadImage();
    const __maxLength = this.maxLength();
    const __extensions = this.extensions();
    const __editorClass = this.editorClass();
    this.lastHtml = this.html();

    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    const nodeViewExtensions = (this.nodeViewTpl ?? this.__rozieFillMap()['nodeView'] ?? this.templates()?.['nodeView']) && __nodeSpecs.length ? this.makeNodeViewExtensions(portals.nodeView, __nodeSpecs) : [];

    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    const placeholderExtensions = __placeholder ? [Placeholder.configure({
      placeholder: __placeholder
    })] : [];

    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    if ((this.bubbleMenuTpl ?? this.__rozieFillMap()['bubbleMenu'] ?? this.templates()?.['bubbleMenu'])) {
      this.bubbleMenuEl = document.createElement('div');
      this.bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
    }
    if ((this.floatingMenuTpl ?? this.__rozieFillMap()['floatingMenu'] ?? this.templates()?.['floatingMenu'])) {
      this.floatingMenuEl = document.createElement('div');
      this.floatingMenuEl.className = 'rozie-tiptap-floating-menu';
    }
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    this.linkEditorEl = document.createElement('div');
    this.linkEditorEl.className = 'rozie-tiptap-link-editor';
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    const menuExtensions = [...(this.bubbleMenuEl ? [BubbleMenu.configure({
      pluginKey: 'rozieBubbleMenu',
      element: this.bubbleMenuEl,
      ...(__bubbleMenuShouldShow ? {
        shouldShow: __bubbleMenuShouldShow
      } : {})
    })] : []), ...(this.floatingMenuEl ? [FloatingMenu.configure({
      element: this.floatingMenuEl
    })] : []), ...(this.linkEditorEl ? [BubbleMenu.configure({
      pluginKey: 'rozieLinkEditor',
      element: this.linkEditorEl,
      // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
      // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
      // button set openFlag (create) — never on a bare selection.
      shouldShow: ({
        editor
      }: any) => editor.isEditable && (editor.isActive('link') || this.openFlag)
    })] : [])];

    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    const imageExtensions = __uploadImage ? [Image] : [];

    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    const needsCount = __maxLength != null || (this.countTpl ?? this.__rozieFillMap()['count'] ?? this.templates()?.['count']);
    const characterCountExtensions = needsCount ? [CharacterCount.configure(this.enforceMaxLength() && __maxLength != null ? {
      limit: __maxLength
    } : {})] : [];

    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    const uploadHandlers = __uploadImage ? {
      handlePaste: this.handlePaste,
      handleDrop: this.handleDrop
    } : {};
    this.editor = new Editor({
      element: this.editorEl()!.nativeElement,
      content: this.html(),
      editable: this.editable(),
      autofocus: this.autofocus(),
      // StarterKit first (config-disabled per the collision scan below); the
      // Placeholder ext next; the reactive node-view nodes next; consumer
      // extensions LAST so they win (TipTap applies later-registered extensions
      // over earlier ones for the same node/mark) — and the whole array is
      // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
      // of the config-level auto-disable (D-02), which is what actually silences
      // StarterKit's internal same-named extension (e.g. its bundled `Link`).
      extensions: this.dedupeExtensionsByName([StarterKit.configure(this.buildStarterKitConfig(this.starterKit(), __extensions)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ...__extensions]),
      editorProps: {
        attributes: {
          'aria-label': this.ariaLabel(),
          ...(__editorClass ? {
            class: __editorClass
          } : {}),
          ...(__placeholder ? {
            'data-placeholder': __placeholder,
            'aria-placeholder': __placeholder
          } : {})
        },
        // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
        // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
        // `{}` (no-op) when $props.uploadImage is unset.
        ...uploadHandlers,
        // Consumer editorProps spread LAST — full ProseMirror editorProps control
        // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
        ...this.editorProps()
      },
      onUpdate: ({
        editor
      }: any) => {
        const next = editor.getHTML();
        this.lastHtml = next;
        // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
        if (next !== this.html()) this.html.set(next), this.__rozieCvaOnChange(next);
        this.refreshCount();
        this.refreshLink();
        this.update.emit(next);
      },
      onSelectionUpdate: () => {
        this.refreshActive();
        this.refreshLink();
        this.selectionUpdate.emit();
      },
      onFocus: () => this.focus.emit(),
      onBlur: ({
        event
      }: any) => {
        // Clear the create-mode latch when focus truly leaves the editor + its link
        // surface — but NOT when it moves INTO the link editor host (clicking the URL
        // input blurs the editor; the buttons are already covered by their keepFocus
        // mousedown). Without this, openFlag stays true after the user dismisses the
        // create affordance by clicking away, so the editor spuriously re-surfaces on
        // the next unrelated selection.
        const to = event && event.relatedTarget;
        if (!(to instanceof Node && this.linkEditorEl && this.linkEditorEl.contains(to))) this.openFlag = false;
        this.blur.emit();
      }
    });
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();

    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    if ((this.toolbarTpl ?? this.__rozieFillMap()['toolbar'] ?? this.templates()?.['toolbar']) && this.toolbarEl()?.nativeElement) {
      this.toolbarDispose = portals.toolbar(this.toolbarEl()!.nativeElement, {
        editor: this.editor
      });
    }

    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    if (this.bubbleMenuEl) {
      this.bubbleMenuDispose = portals.bubbleMenu(this.bubbleMenuEl, {
        editor: this.editor
      });
    }
    if (this.floatingMenuEl) {
      this.floatingMenuDispose = portals.floatingMenu(this.floatingMenuEl, {
        editor: this.editor
      });
    }

    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    if (this.linkEditorEl) {
      if ((this.linkEditorTpl ?? this.__rozieFillMap()['linkEditor'] ?? this.templates()?.['linkEditor'])) {
        // Read the initial link attrs straight off the live editor (NOT
        // `$data.linkState`, written by the refreshLink() call above in this
        // same tick) — the same React stale-read avoidance as buildLinkScope's
        // other call site.
        const initialLinkAttrs = this.editor.getAttributes('link');
        this.linkEditorHandle = portals.linkEditor(this.linkEditorEl, this.buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs));
      } else {
        this.buildDefaultLinkEditor(this.linkEditorEl);
        // Prefill correction (D-04): the refreshLink() call above (right after
        // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
        // exist yet at that point, so every LATER refreshLink() for the same
        // link early-returns, leaving the just-created input empty even when the
        // caret starts inside a link. Seed it directly from the LIVE editor
        // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
        // $data key immediately after refreshLink() just wrote it hits the
        // React setState-is-async stale-read trap (the same write-then-read-in-
        // one-handler class ROZ138 warns about elsewhere in this file), since
        // $data.linkState was written by the refreshLink() call directly above.
        // `editor` is a plain instance handle, not reactive state, so reading it
        // straight off the engine is synchronous and target-uniform. A no-link
        // mount leaves this the empty string (unchanged).
        if (this.linkInputEl) this.linkInputEl.value = this.editor.getAttributes('link').href || '';
      }
    }
    this.__rozieDestroyRef.onDestroy(() => {
      this.toolbarDispose?.();
      this.toolbarDispose = null;
      this.bubbleMenuDispose?.();
      this.bubbleMenuDispose = null;
      this.floatingMenuDispose?.();
      this.floatingMenuDispose = null;
      this.linkEditorHandle?.dispose();
      this.linkEditorHandle = null;
      this.linkEditorEl = null;
      this.linkInputEl = null;
      this.editor?.destroy();
    });
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  editor: any = null;
  lastHtml: any = null;
  toolbarDispose: any = null;
  bubbleMenuEl: any = null;
  bubbleMenuDispose: any = null;
  floatingMenuEl: any = null;
  floatingMenuDispose: any = null;
  linkEditorEl: any = null;
  linkEditorHandle: any = null;
  linkInputEl: any = null;
  openFlag = false;
  lastLinkKey: any = null;
  refreshActive = () => {
    if (!this.editor) return;
    this.active.set({
      bold: this.editor.isActive('bold'),
      italic: this.editor.isActive('italic'),
      h1: this.editor.isActive('heading', {
        level: 1
      }),
      h2: this.editor.isActive('heading', {
        level: 2
      }),
      bulletList: this.editor.isActive('bulletList'),
      underline: this.editor.isActive('underline'),
      orderedList: this.editor.isActive('orderedList'),
      link: this.editor.isActive('link')
    });
  };
  applyLink = (attrs: any) => {
    // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
    // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
    if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
    this.editor?.chain().focus().extendMarkRange('link').setLink(attrs).run();
    this.openFlag = false;
  };
  removeLink = () => {
    this.editor?.chain().focus().extendMarkRange('link').unsetLink().run();
    this.openFlag = false;
  };
  forceMenuRecheck = () => {
    if (!this.editor) return;
    const visible = this.editor.isEditable && (this.editor.isActive('link') || this.openFlag);
    this.editor.view.dispatch(this.editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
  };
  closeLink = () => {
    this.openFlag = false;
    // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
    // current link href. The surface itself is link-anchored (like Google Docs) — it
    // stays while the caret is on a link and hides once openFlag is clear and the
    // caret is off any link (or the doc is not editable).
    if (this.linkInputEl) this.linkInputEl.value = this.linkState().href;
    this.editor?.commands.focus();
    this.forceMenuRecheck();
  };
  buildLinkScope = (href: any, attrs: any) => ({
    editor: this.editor,
    href,
    attrs,
    setLink: this.applyLink,
    unsetLink: this.removeLink,
    close: this.closeLink
  });
  refreshLink = () => {
    if (!this.editor) return;
    const a = this.editor.getAttributes('link');
    const href = a.href || '';
    // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
    // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
    // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
    // change the link).
    const key = href + '' + JSON.stringify(a);
    if (key === this.lastLinkKey) return;
    this.lastLinkKey = key;
    this.linkState.set({
      href,
      attrs: a
    });
    if (this.linkEditorHandle) {
      this.linkEditorHandle.update(this.buildLinkScope(href, a));
    } else if (this.linkInputEl && !this.linkInputEl.matches(':focus')) {
      // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
      // the user typing in this input?" guard holds inside a shadow root — on the Lit
      // target document.activeElement is the shadow HOST, so a document.activeElement
      // check would always miss and stomp the user's in-progress URL. `:focus` is
      // per-element and shadow-boundary-agnostic.
      this.linkInputEl.value = href;
    }
  };
  openLinkEditor = () => {
    this.openFlag = true;
    this.editor?.commands.focus();
    this.refreshLink();
    this.forceMenuRecheck();
  };
  buildDefaultLinkEditor = (el: any) => {
    const input = document.createElement('input');
    input.type = 'text';
    input.className = 'rozie-tiptap-link-input';
    input.placeholder = 'https://…';
    const apply = document.createElement('button');
    apply.type = 'button';
    apply.className = 'rozie-tiptap-link-apply';
    apply.textContent = 'Apply';
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'rozie-tiptap-link-remove';
    remove.textContent = 'Remove';
    const cancel = document.createElement('button');
    cancel.type = 'button';
    cancel.className = 'rozie-tiptap-link-cancel';
    cancel.textContent = 'Cancel';
    // Keep the caret/selection in the document when a control is pressed (a plain
    // click would blur the editor and collapse the selection before the command runs).
    const keepFocus = (e: any) => e.preventDefault();
    for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
    apply.addEventListener('click', () => this.applyLink({
      href: input.value
    }));
    remove.addEventListener('click', this.removeLink);
    cancel.addEventListener('click', this.closeLink);
    input.addEventListener('keydown', (e: any) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        this.applyLink({
          href: input.value
        });
      } else if (e.key === 'Escape') {
        e.preventDefault();
        this.closeLink();
      }
    });
    el.appendChild(input);
    el.appendChild(apply);
    el.appendChild(remove);
    el.appendChild(cancel);
    this.linkInputEl = input;
  };
  refreshCount = () => {
    if (!this.editor) return;
    const storage = this.editor.storage.characterCount;
    this.count.set({
      characters: storage ? storage.characters() : this.editor.getText().length,
      words: storage ? storage.words() : this.editor.getText().split(/\s+/).filter(Boolean).length
    });
  };
  STARTERKIT_COLLISION_MAP = {
    bold: 'bold',
    italic: 'italic',
    strike: 'strike',
    code: 'code',
    heading: 'heading',
    paragraph: 'paragraph',
    blockquote: 'blockquote',
    codeBlock: 'codeBlock',
    hardBreak: 'hardBreak',
    horizontalRule: 'horizontalRule',
    bulletList: 'bulletList',
    orderedList: 'orderedList',
    listItem: 'listItem',
    link: 'link',
    underline: 'underline',
    undoRedo: 'undoRedo',
    history: 'undoRedo'
  };
  buildStarterKitConfig = (userConfig: any, exts: any) => {
    const effective = {
      ...userConfig
    };
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      if (typeof name !== 'string') continue;
      const optionKey = this.STARTERKIT_COLLISION_MAP[name];
      if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
    }
    return effective;
  };
  dedupeExtensionsByName = (exts: any) => {
    const byKey = new Map();
    let anonSeq = 0;
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
      byKey.set(key, ext);
    }
    return [...byKey.values()];
  };
  makeNodeView = (nv: any, spec: any) => (props: any) => {
    const {
      node,
      getPos,
      editor: ed
    } = props;
    // hasContentDOM derives from the spec, not a bare boolean: an editable node
    // is one that is NOT an atom and declares `content` (e.g. 'inline*').
    const hasContentDOM = !spec.atom && !!spec.content;
    // engine-owned outer host the consumer fragment mounts into.
    const dom = document.createElement(hasContentDOM ? 'div' : 'span');
    dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
    // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
    // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
    const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
    if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
    const updateAttributes = (attrs: any) => {
      if (typeof getPos !== 'function') return;
      const pos = getPos();
      if (pos == null) return;
      ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
        ...node.attrs,
        ...attrs
      }));
    };
    const buildScope = (n: any, selected: any) => ({
      node: n,
      selected,
      updateAttributes,
      getPos,
      editor: ed,
      ...(contentDOM ? {
        contentDOM
      } : {})
    });

    // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
    // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
    const handle = nv(dom, buildScope(node, false));

    // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
    // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
    // ProseMirror manages `contentDOM` and renders the node's editable children
    // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
    // rendered into `dom` by the per-target reactive portal — synchronously on
    // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
    // Angular (REQ-23). A query-after-render graft (retried across a microtask +
    // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
    // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
    // and the framework never reconciles it away (the hole carries no child binding).
    const graftContentDOM = (attempt: any) => {
      if (!contentDOM) return;
      const hole = dom.querySelector('[data-rozie-hole]');
      if (hole) {
        if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
        return;
      }
      if (attempt < 5) {
        if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
      }
    };
    graftContentDOM(0);

    // After a reactive re-render (chrome update), re-graft so a fragment that
    // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
    // (REQ-24 — the editable subtree survives every chrome update).
    const updateInPlace = (n: any, selected: any) => {
      handle.update(buildScope(n, selected));
      if (contentDOM) graftContentDOM(0);
    };
    return {
      dom,
      ...(contentDOM ? {
        contentDOM
      } : {}),
      // attr / content change for THIS node → re-render the fragment in place,
      // keep the view (return true). The new node identity is forwarded so the
      // fragment reads fresh node.attrs (REQ-26).
      update(nextNode: any) {
        if (nextNode.type !== node.type) return false;
        updateInPlace(nextNode, false);
        return true;
      },
      // NodeSelection enters/leaves the node → toggle `selected` in scope so the
      // chip's selected styling is pure engine-driven reactive `update`.
      selectNode() {
        updateInPlace(node, true);
      },
      deselectNode() {
        updateInPlace(node, false);
      },
      destroy() {
        handle.dispose();
      }
    };
  };
  parseTagSelector = (selector: any) => {
    const raw = typeof selector === 'string' ? selector : '';
    const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
    const el = elMatch ? elMatch[0] : raw || 'span';
    const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
    if (!attrMatch) return {
      el,
      attr: null,
      value: ''
    };
    const attr = (attrMatch[1] ?? '').trim();
    const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
    return {
      el,
      attr,
      value
    };
  };
  makeNodeViewExtensions = (nv: any, specs: any) => specs.map((spec: any) => {
    // hasContentDOM decides the renderHTML hole: an editable (non-atom,
    // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
    // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
    // a leaf node spec" otherwise).
    const hasContentDOM = !spec.atom && !!spec.content;
    const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
    const {
      el,
      attr,
      value
    } = this.parseTagSelector(firstTag);
    return Node.create({
      name: spec.name,
      group: spec.group ?? 'block',
      inline: spec.inline ?? false,
      atom: spec.atom ?? false,
      selectable: spec.selectable ?? true,
      defining: spec.defining ?? false,
      ...(spec.content ? {
        content: spec.content
      } : {}),
      addAttributes: () => spec.attrs ?? {},
      parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
        tag: t
      })),
      renderHTML: ({
        HTMLAttributes
      }: any) => hasContentDOM ? [el, {
        ...(attr ? {
          [attr]: value
        } : {}),
        ...HTMLAttributes
      }, 0] : [el, {
        ...(attr ? {
          [attr]: value
        } : {}),
        ...HTMLAttributes
      }],
      addNodeView: () => this.makeNodeView(nv, spec)
    });
  });
  findImageFile = (files: any) => {
    if (!files) return undefined;
    for (let i = 0; i < files.length; i++) {
      const f = files[i];
      if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
    }
    return undefined;
  };
  handlePaste = (view: any, event: any, slice: any) => {
    // Captured into a local (not repeated `$props.uploadImage` member reads) so
    // the null-check narrows the type on every target — including Lit, where
    // the Function prop lowers to a nullable function type and a bare
    // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
    // typecheck (TS2721) even though this handler is only ever wired into
    // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
    // gate already guarantees this in practice).
    const upload = this.uploadImage();
    if (!upload) return false;
    const file = this.findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
    if (!file) return false;
    event.preventDefault();
    upload(file).then((url: any) => {
      this.editor?.chain().focus().setImage({
        src: url
      }).run();
    }).catch(() => {});
    return true;
  };
  handleDrop = (view: any, event: any, slice: any, moved: any) => {
    if (moved) return false;
    // See handlePaste — local capture for the same cross-target null-narrowing.
    const upload = this.uploadImage();
    if (!upload) return false;
    const file = this.findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
    if (!file) return false;
    event.preventDefault();
    const pos = view.posAtCoords({
      left: event.clientX,
      top: event.clientY
    });
    upload(file).then((url: any) => {
      const insertPos = pos ? pos.pos : this.editor ? this.editor.state.selection.head : 0;
      this.editor?.chain().focus().insertContentAt(insertPos, {
        type: 'image',
        attrs: {
          src: url
        }
      }).run();
    }).catch(() => {});
    return true;
  };
  getEditor = () => {
    return this.editor;
  };
  focusEditor = () => {
    this.editor?.commands.focus();
  };
  blurEditor = () => {
    this.editor?.commands.blur();
  };
  getHTML = () => {
    return this.editor ? this.editor.getHTML() : '';
  };
  getJSON = () => {
    return this.editor ? this.editor.getJSON() : null;
  };
  getText = () => {
    return this.editor ? this.editor.getText() : '';
  };
  setContent = (next: any) => {
    if (!this.editor) return;
    const v = next ?? '';
    if (v === this.lastHtml) return;
    this.lastHtml = v;
    this.editor.commands.setContent(v, {
      emitUpdate: false
    });
    this.html.set(v), this.__rozieCvaOnChange(v);
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();
  };
  clearContent = () => {
    if (!this.editor) return;
    this.editor.commands.clearContent();
    this.lastHtml = this.editor.getHTML();
    this.html.set(this.lastHtml), this.__rozieCvaOnChange(this.lastHtml);
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();
  };
  toggleBold = () => {
    this.editor?.chain().focus().toggleBold().run();
    this.refreshActive();
  };
  toggleItalic = () => {
    this.editor?.chain().focus().toggleItalic().run();
    this.refreshActive();
  };
  toggleHeading = (level: any) => {
    this.editor?.chain().focus().toggleHeading({
      level: level ?? 1
    }).run();
    this.refreshActive();
  };
  toggleBulletList = () => {
    this.editor?.chain().focus().toggleBulletList().run();
    this.refreshActive();
  };
  toggleUnderline = () => {
    this.editor?.chain().focus().toggleUnderline().run();
    this.refreshActive();
  };
  toggleOrderedList = () => {
    this.editor?.chain().focus().toggleOrderedList().run();
    this.refreshActive();
  };
  undo = () => {
    this.editor?.chain().focus().undo().run();
    this.refreshActive();
  };
  redo = () => {
    this.editor?.chain().focus().redo().run();
    this.refreshActive();
  };
  chain = () => {
    return this.editor ? this.editor.chain().focus() : null;
  };
  isActive = (name: any, attrs: any) => {
    return this.editor ? this.editor.isActive(name, attrs) : false;
  };
  can = () => {
    return this.editor ? this.editor.can() : null;
  };
  isEmpty = () => {
    return this.editor ? this.editor.isEmpty : true;
  };
  getCharacterCount = () => {
    if (!this.editor) return 0;
    return this.editor.storage.characterCount ? this.editor.storage.characterCount.characters() : this.editor.getText().length;
  };
  getWordCount = () => {
    if (!this.editor) return 0;
    return this.editor.storage.characterCount ? this.editor.storage.characterCount.words() : this.editor.getText().split(/\s+/).filter(Boolean).length;
  };
  setLink = (attrs: any) => {
    this.applyLink(attrs);
  };
  unsetLink = () => {
    this.removeLink();
  };

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

  writeValue(v: string | null): void {
    this.html.set(v ?? '<p>Start writing…</p>');
  }
  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: TipTap,
    _ctx: unknown,
  ): _ctx is CountCtx | ToolbarCtx | BubbleMenuCtx | FloatingMenuCtx | LinkEditorCtx | NodeViewCtx {
    return true;
  }

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

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

export default TipTap;
tsx
import type { JSX } from 'solid-js';
import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';

// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).

__rozieInjectStyle('TipTap-2aeee876', `.rozie-tiptap[data-rozie-s-2aeee876] {
  border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rozie-tiptap-radius, 6px);
  overflow: hidden;
  background: var(--rozie-tiptap-bg, white);
}
.rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
  background: var(--rozie-tiptap-readonly-bg, #fafafa);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
  display: flex;
  align-items: center;
  gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
  padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
  border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
  background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
  padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
  border: var(--rozie-tiptap-button-border, 1px solid transparent);
  background: var(--rozie-tiptap-button-bg, transparent);
  border-radius: var(--rozie-tiptap-button-radius, 3px);
  cursor: pointer;
  font: inherit;
  font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
  min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
  color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
  background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
  background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
  color: var(--rozie-tiptap-button-active-color, white);
  border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
  width: var(--rozie-tiptap-toolbar-sep-width, 1px);
  height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
  background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
  margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
}
.rozie-tiptap-content[data-rozie-s-2aeee876] {
  padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
  min-height: var(--rozie-tiptap-content-min-height, 6rem);
  font: inherit;
  outline: none;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }
.rozie-tiptap-count[data-rozie-s-2aeee876] {
  display: flex;
  justify-content: flex-end;
  padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
  border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
  font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
  color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
}
.rozie-tiptap-count-value.over[data-rozie-s-2aeee876] {
  color: var(--rozie-tiptap-count-over-color, #c0392b);
}
.rozie-tiptap-content .is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
    float: left;
    height: 0;
    pointer-events: none;
  }
.rozie-tiptap-link-editor {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-link-gap, 0.25rem);
    padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
    background: var(--rozie-tiptap-link-bg, #1a1a1a);
    border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rozie-tiptap-link-radius, 6px);
    box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
  }
.rozie-tiptap-link-input {
    font: inherit;
    font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
    min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
    border: var(--rozie-tiptap-link-input-border, 1px solid #444);
    border-radius: var(--rozie-tiptap-link-input-radius, 4px);
    background: var(--rozie-tiptap-link-input-bg, #fff);
    color: var(--rozie-tiptap-link-input-color, #000);
  }
.rozie-tiptap-link-editor button {
    font: inherit;
    font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
    border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
    border-radius: var(--rozie-tiptap-link-button-radius, 4px);
    background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
    color: var(--rozie-tiptap-link-button-color, #fff);
    cursor: pointer;
  }
.rozie-tiptap-link-editor button:hover {
    background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
  }
.rozie-tiptap-link-editor .rozie-tiptap-link-remove {
    color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
  }`);

interface CountSlotCtx { characters: any; words: any; maxLength: any; over: any; }

interface ToolbarSlotCtx { editor: any; }

interface BubbleMenuSlotCtx { editor: any; }

interface FloatingMenuSlotCtx { editor: any; }

interface LinkEditorSlotCtx { editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any; }

interface NodeViewSlotCtx { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any; }

interface TipTapProps {
  /**
   * The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
   * @example
   * <TipTap r-model:html="content" placeholder="Start writing…" />
   */
  html?: string;
  defaultHtml?: string;
  onHtmlChange?: (html: string) => void;
  /**
   * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
   */
  editable?: boolean;
  /**
   * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
   */
  placeholder?: string;
  /**
   * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
   */
  autofocus?: boolean;
  /**
   * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
   */
  editorClass?: string;
  /**
   * The accessible name (`aria-label`) applied to the contenteditable element.
   */
  ariaLabel?: string;
  /**
   * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
   */
  editorProps?: Record<string, any>;
  /**
   * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
   */
  extensions?: any[];
  /**
   * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
   */
  starterKit?: Record<string, any>;
  /**
   * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
   * @example
   * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
   */
  nodeSpecs?: any[];
  /**
   * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
   * @example
   * <TipTap :upload-image="uploadFn" />
   */
  uploadImage?: ((...args: any[]) => any) | null;
  /**
   * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
   * @example
   * <TipTap :max-length="500" />
   */
  maxLength?: (number) | null;
  /**
   * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
   */
  enforceMaxLength?: boolean;
  /**
   * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
   * @example
   * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
   */
  bubbleMenuShouldShow?: ((...args: any[]) => any) | null;
  onUpdate?: (...args: unknown[]) => void;
  onSelectionUpdate?: (...args: unknown[]) => void;
  onFocus?: (...args: unknown[]) => void;
  onBlur?: (...args: unknown[]) => void;
  countSlot?: (ctx: CountSlotCtx) => JSX.Element;
  toolbarSlot?: (ctx: ToolbarSlotCtx) => JSX.Element;
  bubbleMenuSlot?: (ctx: BubbleMenuSlotCtx) => JSX.Element;
  floatingMenuSlot?: (ctx: FloatingMenuSlotCtx) => JSX.Element;
  linkEditorSlot?: (ctx: () => LinkEditorSlotCtx) => JSX.Element;
  nodeViewSlot?: (ctx: () => NodeViewSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: TipTapHandle) => void;
}

export interface TipTapHandle {
  getEditor: (...args: any[]) => any;
  focusEditor: (...args: any[]) => any;
  blurEditor: (...args: any[]) => any;
  getHTML: (...args: any[]) => any;
  getJSON: (...args: any[]) => any;
  getText: (...args: any[]) => any;
  setContent: (...args: any[]) => any;
  clearContent: (...args: any[]) => any;
  toggleBold: (...args: any[]) => any;
  toggleItalic: (...args: any[]) => any;
  toggleHeading: (...args: any[]) => any;
  toggleBulletList: (...args: any[]) => any;
  toggleUnderline: (...args: any[]) => any;
  toggleOrderedList: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  chain: (...args: any[]) => any;
  isActive: (...args: any[]) => any;
  can: (...args: any[]) => any;
  isEmpty: (...args: any[]) => any;
  getCharacterCount: (...args: any[]) => any;
  getWordCount: (...args: any[]) => any;
  openLinkEditor: (...args: any[]) => any;
  setLink: (...args: any[]) => any;
  unsetLink: (...args: any[]) => any;
}

export default function TipTap(_props: TipTapProps): JSX.Element {
  const _merged = mergeProps({ editable: true, placeholder: '', autofocus: false, editorClass: '', ariaLabel: 'Rich text editor', editorProps: (() => ({}))() as Record<string, any>, extensions: (() => [])() as any[], starterKit: (() => ({}))() as Record<string, any>, nodeSpecs: (() => [])() as any[], uploadImage: null, maxLength: null, enforceMaxLength: false, bubbleMenuShouldShow: null }, _props);
  const [local, attrs] = splitProps(_merged, ['html', 'editable', 'placeholder', 'autofocus', 'editorClass', 'ariaLabel', 'editorProps', 'extensions', 'starterKit', 'nodeSpecs', 'uploadImage', 'maxLength', 'enforceMaxLength', 'bubbleMenuShouldShow', 'ref', 'onUpdate', 'onSelectionUpdate', 'onFocus', 'onBlur']);
  onMount(() => { local.ref?.({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList, undo, redo, chain, isActive, can, isEmpty, getCharacterCount, getWordCount, openLinkEditor, setLink, unsetLink }); });

  const [html, setHtml] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'html', '<p>Start writing…</p>');
  const [active, setActive] = createSignal({
    bold: false,
    italic: false,
    h1: false,
    h2: false,
    bulletList: false,
    underline: false,
    orderedList: false,
    link: false
  });
  const [count, setCount] = createSignal({
    characters: 0,
    words: 0
  });
  const [linkState, setLinkState] = createSignal({
    href: '',
    attrs: {}
  });
  interface ReactivePortalHandle {
    update(scope: unknown): void;
    dispose(): void;
  }
  const portalDisposers = new Set<() => void>();
  const portals = {
    toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _props.toolbarSlot ?? _props.slots?.['toolbar'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _props.bubbleMenuSlot ?? _props.slots?.['bubbleMenu'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
      const slot = _props.floatingMenuSlot ?? _props.slots?.['floatingMenu'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
    linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
      const slot = _props.linkEditorSlot ?? _props.slots?.['linkEditor'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
      const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
      const dispose = render(() => slot(scopeSig as unknown as (() => { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown })), container);
      portalDisposers.add(dispose);
      return {
        update: (s: unknown): void => {
          setScopeSig(s);
        },
        dispose: (): void => {
          dispose();
          portalDisposers.delete(dispose);
        },
      };
    },
    nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
      const slot = _props.nodeViewSlot ?? _props.slots?.['nodeView'];
      if (typeof slot !== 'function') return { update() {}, dispose() {} };
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
      const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
      const dispose = render(() => slot(scopeSig as unknown as (() => { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: 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 = (() => {
    lastHtml = html();

    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    const nodeViewExtensions = (_props.nodeViewSlot ?? _props.slots?.["nodeView"]) && local.nodeSpecs.length ? makeNodeViewExtensions(portals.nodeView, local.nodeSpecs) : [];

    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    const placeholderExtensions = local.placeholder ? [Placeholder.configure({
      placeholder: local.placeholder
    })] : [];

    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    if ((_props.bubbleMenuSlot ?? _props.slots?.["bubbleMenu"])) {
      bubbleMenuEl = document.createElement('div');
      bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
    }
    if ((_props.floatingMenuSlot ?? _props.slots?.["floatingMenu"])) {
      floatingMenuEl = document.createElement('div');
      floatingMenuEl.className = 'rozie-tiptap-floating-menu';
    }
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    linkEditorEl = document.createElement('div');
    linkEditorEl.className = 'rozie-tiptap-link-editor';
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
      pluginKey: 'rozieBubbleMenu',
      element: bubbleMenuEl,
      ...(local.bubbleMenuShouldShow ? {
        shouldShow: local.bubbleMenuShouldShow
      } : {})
    })] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
      element: floatingMenuEl
    })] : []), ...(linkEditorEl ? [BubbleMenu.configure({
      pluginKey: 'rozieLinkEditor',
      element: linkEditorEl,
      // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
      // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
      // button set openFlag (create) — never on a bare selection.
      shouldShow: ({
        editor
      }: any) => editor.isEditable && (editor.isActive('link') || openFlag)
    })] : [])];

    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    const imageExtensions = local.uploadImage ? [Image] : [];

    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    const needsCount = local.maxLength != null || (_props.countSlot ?? _props.slots?.["count"]);
    const characterCountExtensions = needsCount ? [CharacterCount.configure(local.enforceMaxLength && local.maxLength != null ? {
      limit: local.maxLength ?? undefined
    } : {})] : [];

    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    const uploadHandlers = local.uploadImage ? {
      handlePaste,
      handleDrop
    } : {};
    editor = new Editor({
      element: editorElRef,
      content: html(),
      editable: local.editable,
      autofocus: local.autofocus,
      // StarterKit first (config-disabled per the collision scan below); the
      // Placeholder ext next; the reactive node-view nodes next; consumer
      // extensions LAST so they win (TipTap applies later-registered extensions
      // over earlier ones for the same node/mark) — and the whole array is
      // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
      // of the config-level auto-disable (D-02), which is what actually silences
      // StarterKit's internal same-named extension (e.g. its bundled `Link`).
      extensions: dedupeExtensionsByName([StarterKit.configure(buildStarterKitConfig(local.starterKit, local.extensions)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ...local.extensions]),
      editorProps: {
        attributes: {
          'aria-label': local.ariaLabel,
          ...(local.editorClass ? {
            class: local.editorClass
          } : {}),
          ...(local.placeholder ? {
            'data-placeholder': local.placeholder,
            'aria-placeholder': local.placeholder
          } : {})
        },
        // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
        // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
        // `{}` (no-op) when $props.uploadImage is unset.
        ...uploadHandlers,
        // Consumer editorProps spread LAST — full ProseMirror editorProps control
        // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
        ...local.editorProps
      },
      onUpdate: ({
        editor
      }: any) => {
        const next = editor.getHTML();
        lastHtml = next;
        // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
        if (next !== html()) setHtml(next);
        refreshCount();
        refreshLink();
        _props.onUpdate?.(next);
      },
      onSelectionUpdate: () => {
        refreshActive();
        refreshLink();
        _props.onSelectionUpdate?.();
      },
      onFocus: () => _props.onFocus?.(),
      onBlur: ({
        event
      }: any) => {
        // Clear the create-mode latch when focus truly leaves the editor + its link
        // surface — but NOT when it moves INTO the link editor host (clicking the URL
        // input blurs the editor; the buttons are already covered by their keepFocus
        // mousedown). Without this, openFlag stays true after the user dismisses the
        // create affordance by clicking away, so the editor spuriously re-surfaces on
        // the next unrelated selection.
        const to = event && event.relatedTarget;
        if (!(to instanceof Node && linkEditorEl && linkEditorEl.contains(to))) openFlag = false;
        _props.onBlur?.();
      }
    });
    refreshActive();
    refreshCount();
    refreshLink();

    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    if ((_props.toolbarSlot ?? _props.slots?.["toolbar"]) && toolbarElRef) {
      toolbarDispose = portals.toolbar(toolbarElRef!, {
        editor
      });
    }

    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    if (bubbleMenuEl) {
      bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
        editor
      });
    }
    if (floatingMenuEl) {
      floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
        editor
      });
    }

    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    if (linkEditorEl) {
      if ((_props.linkEditorSlot ?? _props.slots?.["linkEditor"])) {
        // Read the initial link attrs straight off the live editor (NOT
        // `$data.linkState`, written by the refreshLink() call above in this
        // same tick) — the same React stale-read avoidance as buildLinkScope's
        // other call site.
        const initialLinkAttrs = editor.getAttributes('link');
        linkEditorHandle = portals.linkEditor(linkEditorEl, buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs));
      } else {
        buildDefaultLinkEditor(linkEditorEl);
        // Prefill correction (D-04): the refreshLink() call above (right after
        // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
        // exist yet at that point, so every LATER refreshLink() for the same
        // link early-returns, leaving the just-created input empty even when the
        // caret starts inside a link. Seed it directly from the LIVE editor
        // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
        // $data key immediately after refreshLink() just wrote it hits the
        // React setState-is-async stale-read trap (the same write-then-read-in-
        // one-handler class ROZ138 warns about elsewhere in this file), since
        // $data.linkState was written by the refreshLink() call directly above.
        // `editor` is a plain instance handle, not reactive state, so reading it
        // straight off the engine is synchronous and target-uniform. A no-link
        // mount leaves this the empty string (unchanged).
        if (linkInputEl) linkInputEl.value = editor.getAttributes('link').href || '';
      }
    }
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => {
    toolbarDispose?.();
    toolbarDispose = null;
    bubbleMenuDispose?.();
    bubbleMenuDispose = null;
    floatingMenuDispose?.();
    floatingMenuDispose = null;
    linkEditorHandle?.dispose();
    linkEditorHandle = null;
    linkEditorEl = null;
    linkInputEl = null;
    editor?.destroy();
  });
  });
  createEffect(on(() => (() => html())(), (v) => untrack(() => ((v: any) => {
    if (!editor) return;
    if (v === lastHtml) return;
    lastHtml = v;
    editor.commands.setContent(v, {
      emitUpdate: false
    });
    refreshActive();
    refreshCount();
    refreshLink();
  })(v)), { defer: true }));
  createEffect(on(() => (() => local.editable)(), (v) => untrack(() => ((v: any) => editor?.setEditable(v, false))(v)), { defer: true }));
  let toolbarElRef: HTMLElement | null = null;
  let editorElRef: HTMLElement | null = null;

  // The live editor instance — null before mount / after destroy. Named `editor`
  // (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
  // declaration trap (the Chart.js canvasEl/canvasNode lesson).
  let editor: any = null;

  // The raw HTML string the editor currently reflects. Compared against in the
  // $props.html reconciler so the watcher's mount-time fire is a no-op: the
  // editor is created with `content: $props.html`, so right after mount the bound
  // model already matches and setContent must NOT re-run (re-running it replaces
  // the whole ProseMirror document and resets the selection — the official
  // @tiptap/* wrappers guard the same way against the *raw* value, never against
  // the normalized `editor.getHTML()`). This is the CodeMirror suppress-echo
  // guard in HTML-string form (flatpickr lineage).
  let lastHtml: any = null;

  // The `toolbar` portal slot's dispose handle. COMPONENT-scope (top-level let),
  // NOT a $onMount-local — the Solid emitter hoists the $onMount-returned cleanup
  // into a sibling onCleanup() OUTSIDE the mount-body IIFE, so a mount-local would
  // lose scope there (the Chart.js tooltipEl/tooltipDispose hoist lesson).
  let toolbarDispose: any = null;

  // The `bubbleMenu` / `floatingMenu` portal-slot dispose handles + the imperatively
  // created menu host elements. COMPONENT-scope for the same hoist reason as
  // toolbarDispose — and the host els must be reachable from BOTH the pre-`new
  // Editor` extension build (the menu extension needs its `element` at construction)
  // AND the post-construction portal mount, so they live here too (not $onMount
  // locals). Each stays null when its slot is unfilled (zero overhead, no $portals
  // reference fired — the nodeView discipline).
  let bubbleMenuEl: any = null;
  let bubbleMenuDispose: any = null;
  let floatingMenuEl: any = null;
  let floatingMenuDispose: any = null;

  // ── Link editor (#2) surface. Its OWN dedicated bubble-menu instance (distinct
  // `pluginKey: 'rozieLinkEditor'`) with a link-aware trigger, orthogonal to the
  // general `bubbleMenu` slot. `linkEditorEl` is the imperatively-created host handed
  // to that BubbleMenu extension (the bubbleMenuEl discipline — engine owns
  // positioning). COMPONENT-scope for the same hoist reason as the menu els.
  //   - When the consumer fills the `#linkEditor` slot → `linkEditorHandle` is the
  //     REACTIVE portal handle ({ update, dispose }); refreshLink() re-renders it in
  //     place (Spike 016 proved a reactive portal survives the bubble-menu
  //     extension's element.remove()/appendChild detach-reattach cycles).
  //   - Otherwise → the component builds its OWN default form imperatively into
  //     `linkEditorEl` (`linkInputEl` = its URL <input>); refreshLink() imperatively
  //     refreshes the input value. Pure-script ⇒ byte-identical across all 6 targets,
  //     no framework-reconciliation risk, and no portal default-content (the emitter
  //     renders none for an unfilled portal slot).
  // `openFlag` = the toolbar Link button's create-mode trigger (set true on click,
  // cleared on Apply/Remove/Cancel/blur); the link-aware shouldShow shows the editor
  // when `editor.isActive('link')` (edit mode) OR `openFlag` (create mode).
  let linkEditorEl: any = null;
  let linkEditorHandle: any = null;
  let linkInputEl: any = null;
  let openFlag = false;
  // Last link state refreshLink() reflected, as a compare key — lets refreshLink
  // early-return when the link mark is unchanged (a keystroke fires BOTH onUpdate
  // and onSelectionUpdate, so refreshLink would otherwise run — and re-render the
  // #linkEditor fragment — twice per keystroke).
  let lastLinkKey: any = null;

  // Recompute the internal toolbar's active-mark booleans from the live editor.
  function refreshActive() {
    if (!editor) return;
    setActive({
      bold: editor.isActive('bold'),
      italic: editor.isActive('italic'),
      h1: editor.isActive('heading', {
        level: 1
      }),
      h2: editor.isActive('heading', {
        level: 2
      }),
      bulletList: editor.isActive('bulletList'),
      underline: editor.isActive('underline'),
      orderedList: editor.isActive('orderedList'),
      link: editor.isActive('link')
    });
  }

  // ── Link editor (#2) command helpers + reactive refresh. TOP-LEVEL const arrows
  // (siblings of refreshActive/refreshCount) so every `editor` read sits at the same
  // shallow, proven-safe depth — never nested inside an object-literal method (the
  // redirectNestedThis gap [[project_emitter_redirect_nested_this_gap]]). The link
  // scope's setLink/unsetLink/close are these top-level fns, referenced by identity
  // from buildLinkScope so the consumer fragment (and the built-in form) call the
  // SAME verbs. `extendMarkRange('link')` widens the selection to the whole link so
  // an edit/removal applies to the entire mark, not just the caret word.
  //
  // DECLARATION ORDER IS LOAD-BEARING (topological, leaves first): apply/remove/close
  // → buildLinkScope → refreshLink → openLinkEditor. The React/Solid/Lit emitters lift
  // reactive closures into useCallback/memo with eager dependency ARRAYS, so a forward
  // reference to a later-declared reactive const is a hard TS2448 (use-before-decl) —
  // unlike a deferred function BODY, which is fine. apply/removeLink therefore do NOT
  // call refreshLink (which would make them depend on it and re-introduce a cycle):
  // the setLink/unsetLink chain dispatches a transaction that fires onSelectionUpdate +
  // onUpdate, both of which already call refreshLink. Only openLinkEditor (safely last)
  // calls it, for immediate prefill on the create affordance.
  function applyLink(attrs: any) {
    // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
    // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
    if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
    editor?.chain().focus().extendMarkRange('link').setLink(attrs).run();
    openFlag = false;
  }
  function removeLink() {
    editor?.chain().focus().extendMarkRange('link').unsetLink().run();
    openFlag = false;
  }
  // Force the link-editor BubbleMenu to open/close right now, matching openFlag
  // (bug fix — quick 260809-6zp). `editor?.commands.focus()` alone is NOT
  // sufficient: TipTap's `focus` command early-returns with NO dispatch whenever
  // `view.hasFocus() && position === null` (the whole document is already
  // focused — the COMMON case once the create/close affordance is invoked from
  // a `@mousedown.prevent`-guarded control, which deliberately never blurs the
  // editor). And even a dispatched but otherwise-INERT transaction (no doc/
  // selection change) is not enough either: @tiptap/extension-bubble-menu's own
  // `update()` short-circuits with `isSame = !selectionChanged && !docChanged`
  // BEFORE it ever re-runs `shouldShow` — so a no-op dispatch is silently
  // swallowed by the extension's OWN guard, not just TipTap's `focus` command.
  // The extension's `transactionHandler` (its own doc comment: "This allows
  // external code to trigger ... via `editor.view.dispatch(editor.state.tr
  // .setMeta(pluginKey, 'updatePosition'))`") is the official escape hatch: a
  // transaction tagged with THIS surface's own `pluginKey` ('rozieLinkEditor')
  // calls `show()`/`hide()` directly, bypassing both guards. This bit both
  // `openLinkEditor` (create-mode toolbar button) and `closeLink` (built-in
  // Cancel AND any consumer `close()`), on every target, whenever the editor
  // was already focused. `editor` is the raw TipTap `Editor` instance on all 6.
  function forceMenuRecheck() {
    if (!editor) return;
    const visible = editor.isEditable && (editor.isActive('link') || openFlag);
    editor.view.dispatch(editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
  }
  function closeLink() {
    openFlag = false;
    // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
    // current link href. The surface itself is link-anchored (like Google Docs) — it
    // stays while the caret is on a link and hides once openFlag is clear and the
    // caret is off any link (or the doc is not editable).
    if (linkInputEl) linkInputEl.value = linkState().href;
    editor?.commands.focus();
    forceMenuRecheck();
  }
  // The reactive `#linkEditor` slot scope — keys EXACTLY { editor, href, attrs,
  // setLink, unsetLink, close } (spec §5.3). `attrs` is the raw link mark attrs
  // object so a consumer can read custom attrs (e.g. data-course-link); setLink
  // forwards whatever attrs object it is handed VERBATIM (REQ-42 — persistence of a
  // custom attr is the consumer's Link.extend concern, not this wrapper's).
  //
  // Takes `href`/`attrs` as PARAMETERS rather than reading `$data.linkState` —
  // every caller has just computed (or is about to compute) these values
  // directly from the live editor, and reading them back off `$data`
  // immediately after a same-tick write hits the React setState-is-async
  // stale-read trap (the D-04 prefill fix's own class of bug, here on the
  // ONGOING reactive-refresh path rather than the one-time mount path). Passing
  // them straight through keeps every target reading the value that was ACTUALLY
  // just computed, not a framework-buffered echo of it.
  function buildLinkScope(href: any, attrs: any) {
    return {
      editor,
      href,
      attrs,
      setLink: applyLink,
      unsetLink: removeLink,
      close: closeLink
    };
  }
  // Recompute link state from the live editor + drive the surface. Called from
  // onSelectionUpdate + onUpdate (and after content sets). When the consumer slot is
  // filled, re-render the reactive portal in place; otherwise refresh the built-in
  // form's input value — but NOT while the user is typing in it (don't stomp mid-edit).
  function refreshLink() {
    if (!editor) return;
    const a = editor.getAttributes('link');
    const href = a.href || '';
    // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
    // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
    // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
    // change the link).
    const key = href + '' + JSON.stringify(a);
    if (key === lastLinkKey) return;
    lastLinkKey = key;
    setLinkState({
      href,
      attrs: a
    });
    if (linkEditorHandle) {
      linkEditorHandle.update(buildLinkScope(href, a));
    } else if (linkInputEl && !linkInputEl.matches(':focus')) {
      // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
      // the user typing in this input?" guard holds inside a shadow root — on the Lit
      // target document.activeElement is the shadow HOST, so a document.activeElement
      // check would always miss and stomp the user's in-progress URL. `:focus` is
      // per-element and shadow-boundary-agnostic.
      linkInputEl.value = href;
    }
  }
  // Toolbar Link button (create affordance, ask C's deferred button): flip the
  // open flag so the link-aware shouldShow surfaces the editor on the current
  // selection, prefilled with any existing href. Declared AFTER refreshLink so its
  // reactive dep array references an already-declared const (see order note above).
  function openLinkEditor() {
    openFlag = true;
    editor?.commands.focus();
    refreshLink();
    forceMenuRecheck();
  }
  // Build the batteries-included default link-editor form imperatively into the
  // engine-managed host (the bubble-menu extension owns positioning). Vanilla DOM
  // so it is byte-identical across all 6 targets and the framework never reconciles
  // it. Enter = Apply, Escape = Cancel. Used ONLY when the `#linkEditor` slot is
  // unfilled; a filled slot renders the consumer fragment via the reactive portal.
  function buildDefaultLinkEditor(el: any) {
    const input = document.createElement('input');
    input.type = 'text';
    input.className = 'rozie-tiptap-link-input';
    input.placeholder = 'https://…';
    const apply = document.createElement('button');
    apply.type = 'button';
    apply.className = 'rozie-tiptap-link-apply';
    apply.textContent = 'Apply';
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.className = 'rozie-tiptap-link-remove';
    remove.textContent = 'Remove';
    const cancel = document.createElement('button');
    cancel.type = 'button';
    cancel.className = 'rozie-tiptap-link-cancel';
    cancel.textContent = 'Cancel';
    // Keep the caret/selection in the document when a control is pressed (a plain
    // click would blur the editor and collapse the selection before the command runs).
    const keepFocus = (e: any) => e.preventDefault();
    for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
    apply.addEventListener('click', () => applyLink({
      href: input.value
    }));
    remove.addEventListener('click', removeLink);
    cancel.addEventListener('click', closeLink);
    input.addEventListener('keydown', (e: any) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        applyLink({
          href: input.value
        });
      } else if (e.key === 'Escape') {
        e.preventDefault();
        closeLink();
      }
    });
    el.appendChild(input);
    el.appendChild(apply);
    el.appendChild(remove);
    el.appendChild(cancel);
    linkInputEl = input;
  }

  // Recompute the character/word counter from the live editor (D-05). Robust to
  // CharacterCount being absent (maxLength unset, no #count slot): reads
  // `editor.storage.characterCount` when the extension is registered, else falls
  // back to a plain text derivation so `getCharacterCount`/`getWordCount` and the
  // #count slot's numbers are never stale.
  function refreshCount() {
    if (!editor) return;
    const storage = editor.storage.characterCount;
    setCount({
      characters: storage ? storage.characters() : editor.getText().length,
      words: storage ? storage.words() : editor.getText().split(/\s+/).filter(Boolean).length
    });
  }

  // ── StarterKit collision-aware config (ask A). StarterKit bundles several
  // node/mark extensions INTERNALLY (invisible to a top-level array dedup) —
  // e.g. its own `Link`. A consumer supplying a custom same-named extension via
  // `extensions` therefore collides with StarterKit's copy and TipTap warns
  // "Duplicate extension names found" while keeping BOTH; only
  // `StarterKit.configure({ link:false })` actually disables StarterKit's. This
  // map + helper make "consumer wins" true by auto-disabling the StarterKit key
  // whenever the consumer supplies a same-named extension AND has not already
  // decided that key's fate via the `starterKit` prop. Identity for the 15
  // node/mark keys StarterKit exposes as `Partial<Options> | false`, plus the
  // undo/redo option key `undoRedo` — mapped from BOTH its actual installed
  // `.name` (`'undoRedo'`, verified against `@tiptap/extensions@3.23.5`) and the
  // TipTap v2 alias `'history'` as a safety net for a consumer porting a v2
  // History extension. Structural/plumbing StarterKit keys (document, text,
  // dropcursor, gapcursor, listKeymap, trailingNode) are NOT node/mark
  // replacements and are intentionally excluded.
  const STARTERKIT_COLLISION_MAP = {
    bold: 'bold',
    italic: 'italic',
    strike: 'strike',
    code: 'code',
    heading: 'heading',
    paragraph: 'paragraph',
    blockquote: 'blockquote',
    codeBlock: 'codeBlock',
    hardBreak: 'hardBreak',
    horizontalRule: 'horizontalRule',
    bulletList: 'bulletList',
    orderedList: 'orderedList',
    listItem: 'listItem',
    link: 'link',
    underline: 'underline',
    undoRedo: 'undoRedo',
    history: 'undoRedo'
  };

  // Pure helper — returns `userConfig` extended so any StarterKit-bundled
  // node/mark the consumer replaced (a same-named entry in `exts`) is disabled
  // UNLESS the consumer already decided that key's fate in `userConfig` (an `in`
  // presence check, so an explicit `false` OR an explicit options object both
  // count as "consumer decided" — D-02, consumer wins unless configured
  // explicitly). Never invokes consumer code — only reads `.name` and does key
  // presence checks (guards a non-object/missing `.name` entry by skipping it).
  function buildStarterKitConfig(userConfig: any, exts: any) {
    const effective = {
      ...userConfig
    };
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      if (typeof name !== 'string') continue;
      const optionKey = STARTERKIT_COLLISION_MAP[name];
      if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
    }
    return effective;
  }

  // Pure helper — D-03 last-wins safety net over the FINAL assembled extension
  // array. Dedupes by `.name`, keeping the LAST occurrence (later = consumer).
  // A nameless/unnamed entry is never collapsed against another nameless entry
  // — each survives, keyed by a per-entry unique fallback rather than a shared
  // `undefined` key.
  function dedupeExtensionsByName(exts: any) {
    const byKey = new Map();
    let anonSeq = 0;
    for (const ext of exts as any) {
      const name = ext && typeof ext === 'object' ? ext.name : undefined;
      const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
      byKey.set(key, ext);
    }
    return [...byKey.values()];
  }

  // ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
  // portal slot, the marquee TipTap differentiator; generalized in Phase
  // 260719-d9e / ask B). When the consumer fills the `nodeView` slot AND
  // supplies one or more `nodeSpecs`, each spec becomes its own custom
  // ProseMirror node rendering the SAME consumer fragment as a custom node
  // *in-engine*, re-rendering it in place on every transaction via the
  // reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
  // (REQ-22). The fragment dispatches on `scope.node.type.name` to tell the
  // specs apart (D-03 — single-slot-dispatch, no dynamic per-type slot names).
  //
  // A spec with NO `content` (typically `atom:true`) is a NON-EDITABLE node —
  // no contentDOM — driven purely by selectNode/deselectNode/update(node) →
  // handle.update so the fragment re-renders in place (engine-driven; no Rozie
  // reactive loop). Proven originally by the @mention-chip recipe (Spike 009 /
  // REQ-26), now shipped as a `nodeSpecs` entry in the example demos.
  //
  // A spec WITH `content` (e.g. `'inline*'`) is an EDITABLE BLOCK — it HAS a
  // contentDOM. ProseMirror owns the editable hole; the consumer fragment
  // renders chrome wrapping a [data-rozie-hole] placeholder and the per-target
  // portal bridge grafts contentDOM into that hole — native-ref on
  // React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
  // .rozie source merely passes `contentDOM` in scope; the graft mechanism is
  // PER-TARGET and lives in the emitted portal bridge, not here. Proven
  // originally by the editable-callout recipe (Spike 008 / REQ-23), now shipped
  // as a `nodeSpecs` entry in the example demos.
  //
  // $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
  // (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
  // same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
  // from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
  // constructed within the mount lifecycle.
  function makeNodeView(nv: any, spec: any) {
    return (props: any) => {
      const {
        node,
        getPos,
        editor: ed
      } = props;
      // hasContentDOM derives from the spec, not a bare boolean: an editable node
      // is one that is NOT an atom and declares `content` (e.g. 'inline*').
      const hasContentDOM = !spec.atom && !!spec.content;
      // engine-owned outer host the consumer fragment mounts into.
      const dom = document.createElement(hasContentDOM ? 'div' : 'span');
      dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
      // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
      // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
      const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
      if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
      const updateAttributes = (attrs: any) => {
        if (typeof getPos !== 'function') return;
        const pos = getPos();
        if (pos == null) return;
        ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
          ...node.attrs,
          ...attrs
        }));
      };
      const buildScope = (n: any, selected: any) => ({
        node: n,
        selected,
        updateAttributes,
        getPos,
        editor: ed,
        ...(contentDOM ? {
          contentDOM
        } : {})
      });

      // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
      // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
      const handle = nv(dom, buildScope(node, false));

      // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
      // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
      // ProseMirror manages `contentDOM` and renders the node's editable children
      // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
      // rendered into `dom` by the per-target reactive portal — synchronously on
      // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
      // Angular (REQ-23). A query-after-render graft (retried across a microtask +
      // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
      // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
      // and the framework never reconciles it away (the hole carries no child binding).
      const graftContentDOM = (attempt: any) => {
        if (!contentDOM) return;
        const hole = dom.querySelector('[data-rozie-hole]');
        if (hole) {
          if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
          return;
        }
        if (attempt < 5) {
          if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
        }
      };
      graftContentDOM(0);

      // After a reactive re-render (chrome update), re-graft so a fragment that
      // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
      // (REQ-24 — the editable subtree survives every chrome update).
      const updateInPlace = (n: any, selected: any) => {
        handle.update(buildScope(n, selected));
        if (contentDOM) graftContentDOM(0);
      };
      return {
        dom,
        ...(contentDOM ? {
          contentDOM
        } : {}),
        // attr / content change for THIS node → re-render the fragment in place,
        // keep the view (return true). The new node identity is forwarded so the
        // fragment reads fresh node.attrs (REQ-26).
        update(nextNode: any) {
          if (nextNode.type !== node.type) return false;
          updateInPlace(nextNode, false);
          return true;
        },
        // NodeSelection enters/leaves the node → toggle `selected` in scope so the
        // chip's selected styling is pure engine-driven reactive `update`.
        selectNode() {
          updateInPlace(node, true);
        },
        deselectNode() {
          updateInPlace(node, false);
        },
        destroy() {
          handle.dispose();
        }
      };
    };
  }

  // Pure helper (ask B, D-02) — extracts { el, attr, value } from a parseHTML
  // tag selector string, e.g. 'span[data-x]' → { el: 'span', attr: 'data-x',
  // value: '' } or 'div[data-x=y]' → { el: 'div', attr: 'data-x', value: 'y' }.
  // Drives renderHTML's marker attribute so the serialized element reproduces
  // the exact shape the parseHTML rule expects. MUST NOT throw on a
  // malformed/empty selector (T-d9e-01 — a bad selector degrades only that one
  // node's render, never crashes the editor): falls back to el = the raw
  // selector (or 'span' if falsy), attr = null (no marker), value = ''.
  function parseTagSelector(selector: any) {
    const raw = typeof selector === 'string' ? selector : '';
    const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
    const el = elMatch ? elMatch[0] : raw || 'span';
    const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
    if (!attrMatch) return {
      el,
      attr: null,
      value: ''
    };
    const attr = (attrMatch[1] ?? '').trim();
    const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
    return {
      el,
      attr,
      value
    };
  }

  // Build ONE custom Node per consumer-supplied spec, all bound to the SAME
  // reactive nodeView portal (ask B, D-02). Takes the per-target
  // `$portals.nodeView` (captured here so the reference stays inside the mount
  // lifecycle — never top-level, per the bundled-leaf typecheck rule) and the
  // `nodeSpecs` prop array (read once at mount — setup-once, not reactive).
  function makeNodeViewExtensions(nv: any, specs: any) {
    return specs.map((spec: any) => {
      // hasContentDOM decides the renderHTML hole: an editable (non-atom,
      // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
      // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
      // a leaf node spec" otherwise).
      const hasContentDOM = !spec.atom && !!spec.content;
      const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
      const {
        el,
        attr,
        value
      } = parseTagSelector(firstTag);
      return Node.create({
        name: spec.name,
        group: spec.group ?? 'block',
        inline: spec.inline ?? false,
        atom: spec.atom ?? false,
        selectable: spec.selectable ?? true,
        defining: spec.defining ?? false,
        ...(spec.content ? {
          content: spec.content
        } : {}),
        addAttributes: () => spec.attrs ?? {},
        parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
          tag: t
        })),
        renderHTML: ({
          HTMLAttributes
        }: any) => hasContentDOM ? [el, {
          ...(attr ? {
            [attr]: value
          } : {}),
          ...HTMLAttributes
        }, 0] : [el, {
          ...(attr ? {
            [attr]: value
          } : {}),
          ...HTMLAttributes
        }],
        addNodeView: () => makeNodeView(nv, spec)
      });
    });
  }

  // Shared image-file finder for the upload handlers below — the first
  // `image/*` File in a FileList, else undefined. Guards a missing FileList.
  function findImageFile(files: any) {
    if (!files) return undefined;
    for (let i = 0; i < files.length; i++) {
      const f = files[i];
      if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
    }
    return undefined;
  }

  // uploadImage paste/drop fallbacks (ask D / D-04) — ProseMirror `editorProps`
  // handlers. TOP-LEVEL functions (siblings of `refreshActive`/the $expose
  // verbs below), NOT nested inside $onMount's ternary/object-literal — a
  // closure reading the component-scope `editor` from several function-levels
  // deep inside $onMount (object-literal method → `.then` callback) hits a
  // `this`-rebinding gap on the class-based targets (Angular/Lit) that the
  // emitter's nested-`this` repair does not reach at that depth
  // (emitter-backlog). A top-level function is only ONE level removed from the
  // promoted-`this` boundary — the same shallow depth as the `onUpdate` /
  // `$watch` callbacks elsewhere in this file, which already compile clean —
  // so referencing `editor` here needs no repair at all. Each handler claims
  // ONLY an image/* payload: returns `true` SYNCHRONOUSLY (claiming the
  // paste/drop now — never awaits inside the handler) and inserts the resolved
  // URL once the consumer's uploadImage promise settles; a rejection is
  // swallowed (`.catch(() => {})`) so a failed upload never crashes the editor
  // (T-e7i-01). Returns `false` for a non-image payload — or, for drop, an
  // internal node move — so ProseMirror (or a consumer editorProps handler,
  // which still wins via the LAST spread) processes it normally.
  function handlePaste(view: any, event: any, slice: any) {
    // Captured into a local (not repeated `$props.uploadImage` member reads) so
    // the null-check narrows the type on every target — including Lit, where
    // the Function prop lowers to a nullable function type and a bare
    // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
    // typecheck (TS2721) even though this handler is only ever wired into
    // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
    // gate already guarantees this in practice).
    const upload = local.uploadImage;
    if (!upload) return false;
    const file = findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
    if (!file) return false;
    event.preventDefault();
    upload(file).then((url: any) => {
      editor?.chain().focus().setImage({
        src: url
      }).run();
    }).catch(() => {});
    return true;
  }
  function handleDrop(view: any, event: any, slice: any, moved: any) {
    if (moved) return false;
    // See handlePaste — local capture for the same cross-target null-narrowing.
    const upload = local.uploadImage;
    if (!upload) return false;
    const file = findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
    if (!file) return false;
    event.preventDefault();
    const pos = view.posAtCoords({
      left: event.clientX,
      top: event.clientY
    });
    upload(file).then((url: any) => {
      const insertPos = pos ? pos.pos : editor ? editor.state.selection.head : 0;
      editor?.chain().focus().insertContentAt(insertPos, {
        type: 'image',
        attrs: {
          src: url
        }
      }).run();
    }).catch(() => {});
    return true;
  }
  // ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
  // the marquee surface: 25 verbs over the live Editor, uniform across all 6
  // targets. Each guards the pre-mount / destroyed `editor = null`.
  //
  // Collision discipline:
  //   - The content setter is named `setContent`, NOT `setHtml` — an `html` model
  //     prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
  //     $expose verb would collide on the React target (ROZ524). (CodeMirror's
  //     setValue→replaceValue lesson, html edition.)
  //   - None of the 25 names collide with LitElement reserved lifecycle methods
  //     (update/render/firstUpdated/updated/willUpdate/requestUpdate).
  //   - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
  //     `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
  //     class-based targets (Angular) an output field and a method cannot share a
  //     name (ROZ121). The diagnostic's own guidance: rename the method, keep the
  //     event's public name. (The expose-verb-vs-event-name collision lesson.)
  //   - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
  //     ariaLabel/editorProps/extensions).
  function getEditor() {
    return editor;
  }
  function focusEditor() {
    editor?.commands.focus();
  }
  function blurEditor() {
    editor?.commands.blur();
  }
  function getHTML() {
    return editor ? editor.getHTML() : '';
  }
  function getJSON() {
    return editor ? editor.getJSON() : null;
  }
  // Plain-text extraction — word/char counts, search indexing, plaintext export.
  // Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
  // alongside getHTML/getJSON but never wired; now first-class.
  function getText() {
    return editor ? editor.getText() : '';
  }
  // setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
  // update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
  // reflect into the model so a programmatic set keeps the bound state in sync.
  function setContent(next: any) {
    if (!editor) return;
    const v = next ?? '';
    if (v === lastHtml) return;
    lastHtml = v;
    editor.commands.setContent(v, {
      emitUpdate: false
    });
    setHtml(v);
    refreshActive();
    refreshCount();
    refreshLink();
  }
  function clearContent() {
    if (!editor) return;
    editor.commands.clearContent();
    lastHtml = editor.getHTML();
    setHtml(lastHtml);
    refreshActive();
    refreshCount();
    refreshLink();
  }
  function toggleBold() {
    editor?.chain().focus().toggleBold().run();
    refreshActive();
  }
  function toggleItalic() {
    editor?.chain().focus().toggleItalic().run();
    refreshActive();
  }
  function toggleHeading(level: any) {
    editor?.chain().focus().toggleHeading({
      level: level ?? 1
    }).run();
    refreshActive();
  }
  function toggleBulletList() {
    editor?.chain().focus().toggleBulletList().run();
    refreshActive();
  }
  function toggleUnderline() {
    editor?.chain().focus().toggleUnderline().run();
    refreshActive();
  }
  function toggleOrderedList() {
    editor?.chain().focus().toggleOrderedList().run();
    refreshActive();
  }
  function undo() {
    editor?.chain().focus().undo().run();
    refreshActive();
  }
  function redo() {
    editor?.chain().focus().redo().run();
    refreshActive();
  }
  // Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
  // chain().focus().toggleBold().setColor('#f00').run()). null before mount.
  function chain() {
    return editor ? editor.chain().focus() : null;
  }
  // Read-side toolbar primitives. These are precisely what a bring-your-own
  // toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
  // the component already computes internally via refreshActive() — exposing them
  // removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
  //   - isActive(name, attrs?): is a mark/node active in the current selection
  //     (drive toolbar button active styling). False before mount.
  //   - can(): the command-availability chain (editor.can().chain()…run()) for
  //     enable/disable of toolbar buttons. null before mount (mirrors chain()).
  //   - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
  function isActive(name: any, attrs: any) {
    return editor ? editor.isActive(name, attrs) : false;
  }
  function can() {
    return editor ? editor.can() : null;
  }
  function isEmpty() {
    return editor ? editor.isEmpty : true;
  }
  // Character/word count reads (D-04). Prefer the CharacterCount extension's live
  // storage when registered (maxLength set or #count slot filled); otherwise a
  // text-based fallback so these ALWAYS return a number — 0 before mount, and a
  // correct count even on a stock <TipTap> that never registered CharacterCount.
  function getCharacterCount() {
    if (!editor) return 0;
    return editor.storage.characterCount ? editor.storage.characterCount.characters() : editor.getText().length;
  }
  function getWordCount() {
    if (!editor) return 0;
    return editor.storage.characterCount ? editor.storage.characterCount.words() : editor.getText().split(/\s+/).filter(Boolean).length;
  }
  // setLink(attrs) / unsetLink() (D-03, residual 3) — thin delegates to the SAME
  // applyLink/removeLink the #linkEditor slot scope hands a consumer fragment
  // (buildLinkScope above), so the imperative handle and the slot-scope verb
  // implementation cannot disagree. Four-way collision check:
  //   - not a prop name — the 14 props are html / editable / placeholder /
  //     autofocus / editorClass / ariaLabel / editorProps / extensions /
  //     starterKit / nodeSpecs / uploadImage / maxLength / enforceMaxLength /
  //     bubbleMenuShouldShow;
  //   - not an emitted event name — the 4 events are update / selectionUpdate /
  //     focus / blur (the ROZ121 Angular output-field-vs-method rule);
  //   - not an existing $expose verb — the 23 names already in the object below;
  //   - not a React auto-generated model setter — the only model prop is
  //     `html`, whose setter is `setHtml` (the ROZ524 rule that forced
  //     `setContent`), and not a LitElement lifecycle method (update / render /
  //     firstUpdated / updated / willUpdate / requestUpdate).
  // applyLink already ignores an attrs object without a non-empty string href
  // (no degenerate empty-href anchor is ever written), and both verbs no-op
  // before mount / after destroy through the `editor?.` guards already inside
  // applyLink/removeLink — no second validation path is introduced.
  function setLink(attrs: any) {
    applyLink(attrs);
  }
  function unsetLink() {
    removeLink();
  }

  return (
    <>
    <div class={"rozie-tiptap" + " " + rozieClass({ 'is-readonly': !local.editable })} data-rozie-s-2aeee876="">
      
      {<Show when={local.editable && !(_props.toolbarSlot ?? _props.slots?.['toolbar'])}><div class={"rozie-tiptap-toolbar"} data-rozie-s-2aeee876="">
        <button type="button" aria-label="Bold" class={rozieClass({ active: active().bold })} onClick={toggleBold} data-rozie-s-2aeee876=""><strong data-rozie-s-2aeee876="">B</strong></button>
        <button type="button" aria-label="Italic" class={rozieClass({ active: active().italic })} onClick={toggleItalic} data-rozie-s-2aeee876=""><em data-rozie-s-2aeee876="">I</em></button>
        <span class={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" aria-label="Heading 1" class={rozieClass({ active: active().h1 })} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { toggleHeading(1); }} data-rozie-s-2aeee876="">H1</button>
        <button type="button" aria-label="Heading 2" class={rozieClass({ active: active().h2 })} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { toggleHeading(2); }} data-rozie-s-2aeee876="">H2</button>
        <span class={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" aria-label="Bullet list" class={rozieClass({ active: active().bulletList })} onClick={toggleBulletList} data-rozie-s-2aeee876="">• List</button>
        <button type="button" aria-label="Underline" class={rozieClass({ active: active().underline })} onClick={toggleUnderline} data-rozie-s-2aeee876=""><u data-rozie-s-2aeee876="">U</u></button>
        <button type="button" aria-label="Ordered list" class={rozieClass({ active: active().orderedList })} onClick={toggleOrderedList} data-rozie-s-2aeee876="">1. List</button>
        <span class={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" aria-label="Link" class={rozieClass({ active: active().link })} onClick={openLinkEditor} data-rozie-s-2aeee876="">Link</button>
        <span class={"sep"} data-rozie-s-2aeee876="" />
        <button type="button" aria-label="Undo" onClick={undo} data-rozie-s-2aeee876="">↺</button>
        <button type="button" aria-label="Redo" onClick={redo} data-rozie-s-2aeee876="">↻</button>
      </div></Show>}{<Show when={local.editable && (_props.toolbarSlot ?? _props.slots?.['toolbar'])}><div class={"rozie-tiptap-toolbar rozie-tiptap-toolbar--slot"} ref={(el) => { toolbarElRef = el as HTMLElement; }} data-rozie-s-2aeee876="" /></Show>}<div ref={(el) => { editorElRef = el as HTMLElement; }} class={"rozie-tiptap-content"} data-placeholder={local.placeholder} data-rozie-s-2aeee876="" />
      
      {<Show when={local.maxLength != null || (_props.countSlot ?? _props.slots?.['count'])}><div class={"rozie-tiptap-count"} data-rozie-s-2aeee876="">
        {(_props.countSlot ?? _props.slots?.['count'])?.({ characters: count().characters, words: count().words, maxLength: local.maxLength, over: local.maxLength != null && count().characters > local.maxLength }) ?? <span class={"rozie-tiptap-count-value" + " " + rozieClass({ over: local.maxLength != null && count().characters > local.maxLength })} data-rozie-s-2aeee876="">{rozieDisplay(count().characters)} / {local.maxLength}</span>}
      </div></Show>}</div>









    </>
  );
}
ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, injectGlobalStyles, rozieDisplay } from '@rozie/runtime-lit';
import { Editor, Node } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Placeholder } from '@tiptap/extensions';
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image';
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count';

// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).

interface RozieCountSlotCtx {
  characters: any;
  words: any;
  maxLength: any;
  over: any;
}

interface RozieToolbarSlotCtx {
  editor: any;
}

interface RozieBubbleMenuSlotCtx {
  editor: any;
}

interface RozieFloatingMenuSlotCtx {
  editor: any;
}

interface RozieLinkEditorSlotCtx {
  editor: any;
  href: any;
  attrs: any;
  setLink: any;
  unsetLink: any;
  close: any;
}

interface RozieNodeViewSlotCtx {
  node: any;
  selected: any;
  updateAttributes: any;
  getPos: any;
  editor: any;
  contentDOM: any;
}

@customElement('rozie-tip-tap')
export default class TipTap extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.rozie-tiptap[data-rozie-s-2aeee876] {
  border: var(--rozie-tiptap-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rozie-tiptap-radius, 6px);
  overflow: hidden;
  background: var(--rozie-tiptap-bg, white);
}
.rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
  background: var(--rozie-tiptap-readonly-bg, #fafafa);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
  display: flex;
  align-items: center;
  gap: var(--rozie-tiptap-toolbar-gap, 0.125rem);
  padding: var(--rozie-tiptap-toolbar-padding, 0.25rem 0.375rem);
  border-bottom: var(--rozie-tiptap-toolbar-border, 1px solid rgba(0, 0, 0, 0.08));
  background: var(--rozie-tiptap-toolbar-bg, #f5f5f7);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
  padding: var(--rozie-tiptap-button-padding, 0.25rem 0.5rem);
  border: var(--rozie-tiptap-button-border, 1px solid transparent);
  background: var(--rozie-tiptap-button-bg, transparent);
  border-radius: var(--rozie-tiptap-button-radius, 3px);
  cursor: pointer;
  font: inherit;
  font-size: var(--rozie-tiptap-button-font-size, 0.8125rem);
  min-width: var(--rozie-tiptap-button-min-width, 1.75rem);
  color: var(--rozie-tiptap-button-color, rgba(0, 0, 0, 0.65));
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
  background: var(--rozie-tiptap-button-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
  background: var(--rozie-tiptap-button-active-bg, #1a1a1a);
  color: var(--rozie-tiptap-button-active-color, white);
  border-color: var(--rozie-tiptap-button-active-border-color, #1a1a1a);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
  width: var(--rozie-tiptap-toolbar-sep-width, 1px);
  height: var(--rozie-tiptap-toolbar-sep-height, 1rem);
  background: var(--rozie-tiptap-toolbar-sep-bg, rgba(0, 0, 0, 0.1));
  margin: var(--rozie-tiptap-toolbar-sep-margin, 0 0.25rem);
}
.rozie-tiptap-content[data-rozie-s-2aeee876] {
  padding: var(--rozie-tiptap-content-padding, 0.625rem 0.875rem);
  min-height: var(--rozie-tiptap-content-min-height, 6rem);
  font: inherit;
  outline: none;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-p-margin, 0 0 0.5rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h1-font-size, 1.5rem); margin: var(--rozie-tiptap-content-h1-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: var(--rozie-tiptap-content-h2-font-size, 1.25rem); margin: var(--rozie-tiptap-content-h2-margin, 0.5rem 0 0.375rem); }
.rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: var(--rozie-tiptap-content-list-margin, 0 0 0.5rem); padding-left: var(--rozie-tiptap-content-list-indent, 1.5rem); }
.rozie-tiptap-count[data-rozie-s-2aeee876] {
  display: flex;
  justify-content: flex-end;
  padding: var(--rozie-tiptap-count-padding, 0.25rem 0.625rem);
  border-top: var(--rozie-tiptap-count-border, 1px solid rgba(0, 0, 0, 0.08));
  font-size: var(--rozie-tiptap-count-font-size, 0.75rem);
  color: var(--rozie-tiptap-count-color, rgba(0, 0, 0, 0.5));
}
.rozie-tiptap-count-value.over[data-rozie-s-2aeee876] {
  color: var(--rozie-tiptap-count-over-color, #c0392b);
}
.rozie-tiptap-content .is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
    float: left;
    height: 0;
    pointer-events: none;
  }
.rozie-tiptap-link-editor {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-link-gap, 0.25rem);
    padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
    background: var(--rozie-tiptap-link-bg, #1a1a1a);
    border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rozie-tiptap-link-radius, 6px);
    box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
  }
.rozie-tiptap-link-input {
    font: inherit;
    font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
    min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
    border: var(--rozie-tiptap-link-input-border, 1px solid #444);
    border-radius: var(--rozie-tiptap-link-input-radius, 4px);
    background: var(--rozie-tiptap-link-input-bg, #fff);
    color: var(--rozie-tiptap-link-input-color, #000);
  }
.rozie-tiptap-link-editor button {
    font: inherit;
    font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
    border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
    border-radius: var(--rozie-tiptap-link-button-radius, 4px);
    background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
    color: var(--rozie-tiptap-link-button-color, #fff);
    cursor: pointer;
  }
.rozie-tiptap-link-editor button:hover {
    background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
  }
.rozie-tiptap-link-editor .rozie-tiptap-link-remove {
    color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
  }
`;

  /**
   * The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.
   * @example
   * <TipTap r-model:html="content" placeholder="Start writing…" />
   */
  @property({ type: String, attribute: 'html' }) _html_attr: string = '<p>Start writing…</p>';
  private _htmlControllable = createLitControllableProperty<string>({ host: this, eventName: 'html-change', defaultValue: '<p>Start writing…</p>', initialControlledValue: undefined });
  /**
   * Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.
   */
  @property({ type: Boolean, reflect: true }) editable: boolean = true;
  /**
   * Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.
   */
  @property({ type: String, reflect: true }) placeholder: string = '';
  /**
   * Whether to place the caret in the document on mount (TipTap's `autofocus` option).
   */
  @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
  /**
   * A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
   */
  @property({ type: String, reflect: true }) editorClass: string = '';
  /**
   * The accessible name (`aria-label`) applied to the contenteditable element.
   */
  @property({ type: String, reflect: true }) ariaLabel: string = 'Rich text editor';
  /**
   * ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
   */
  @property({ type: Object }) editorProps: any = {};
  /**
   * Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit's without a "Duplicate extension names" warning.
   */
  @property({ type: Array }) extensions: any[] = [];
  /**
   * StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.
   */
  @property({ type: Object }) starterKit: any = {};
  /**
   * Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.
   * @example
   * <TipTap :node-specs="[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]"><template #nodeView="{ node }">…</template></TipTap>
   */
  @property({ type: Array }) nodeSpecs: any[] = [];
  /**
   * An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.
   * @example
   * <TipTap :upload-image="uploadFn" />
   */
  @property({ type: Function }) uploadImage: ((...args: any[]) => any) | null = null;
  /**
   * A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.
   * @example
   * <TipTap :max-length="500" />
   */
  @property({ type: Number, reflect: true }) maxLength: number | null = null;
  /**
   * Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.
   */
  @property({ type: Boolean, reflect: true }) enforceMaxLength: boolean = false;
  /**
   * A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.
   * @example
   * <TipTap :bubble-menu-should-show="({ editor }) => editor.isActive('table')"><template #bubbleMenu="{ editor }">…</template></TipTap>
   */
  @property({ type: Function }) bubbleMenuShouldShow: ((...args: any[]) => any) | null = null;
  private _active = signal({
  bold: false,
  italic: false,
  h1: false,
  h2: false,
  bulletList: false,
  underline: false,
  orderedList: false,
  link: false
});
  private _count = signal({
  characters: 0,
  words: 0
});
  private _linkState = signal({
  href: '',
  attrs: {}
});
  @query('[data-rozie-ref="toolbarEl"]') private _refToolbarEl!: HTMLElement;
  @query('[data-rozie-ref="editorEl"]') private _refEditorEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();

  @state() private _hasSlotCount = false;
  @queryAssignedElements({ slot: 'count', flatten: true }) private _slotCountElements!: Element[];
  @property({ attribute: false }) count?: (scope: { characters: any; words: any; maxLength: any; over: any }) => unknown;
  @state() private _hasSlotToolbar = false;
  @queryAssignedElements({ slot: 'toolbar', flatten: true }) private _slotToolbarElements!: Element[];
  @property({ attribute: false }) toolbar?: (scope: { editor: any }) => unknown;
  @state() private _hasSlotBubbleMenu = false;
  @queryAssignedElements({ slot: 'bubbleMenu', flatten: true }) private _slotBubbleMenuElements!: Element[];
  @property({ attribute: false }) bubbleMenu?: (scope: { editor: any }) => unknown;
  @state() private _hasSlotFloatingMenu = false;
  @queryAssignedElements({ slot: 'floatingMenu', flatten: true }) private _slotFloatingMenuElements!: Element[];
  @property({ attribute: false }) floatingMenu?: (scope: { editor: any }) => unknown;
  @state() private _hasSlotLinkEditor = false;
  @queryAssignedElements({ slot: 'linkEditor', flatten: true }) private _slotLinkEditorElements!: Element[];
  @property({ attribute: false }) linkEditor?: (scope: { editor: any; href: any; attrs: any; setLink: any; unsetLink: any; close: any }) => unknown;
  @state() private _hasSlotNodeView = false;
  @queryAssignedElements({ slot: 'nodeView', flatten: true }) private _slotNodeViewElements!: Element[];
  @property({ attribute: false }) nodeView?: (scope: { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any }) => unknown;
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
  // open-tag binding; the KEY is the fill's authored (possibly
  // non-identifier) name and the VALUE is a scope-taking render
  // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
  // named function-prop / <slot> fallback (AC-9). Attribute
  // deserialization is disabled — this is a function-valued record,
  // never reflected to/from an HTML attribute.
  @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;

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

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="count"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotCount = this._slotCountElements.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="toolbar"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotToolbar = this._slotToolbarElements.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="bubbleMenu"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotBubbleMenu = this._slotBubbleMenuElements.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="floatingMenu"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotFloatingMenu = this._slotFloatingMenuElements.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="linkEditor"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotLinkEditor = this._slotLinkEditorElements.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="nodeView"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotNodeView = this._slotNodeViewElements.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._hasSlotCount = Array.from(this.children).some((el) => el.getAttribute('slot') === 'count');
    this._hasSlotToolbar = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toolbar');
    this._hasSlotBubbleMenu = Array.from(this.children).some((el) => el.getAttribute('slot') === 'bubbleMenu');
    this._hasSlotFloatingMenu = Array.from(this.children).some((el) => el.getAttribute('slot') === 'floatingMenu');
    this._hasSlotLinkEditor = Array.from(this.children).some((el) => el.getAttribute('slot') === 'linkEditor');
    this._hasSlotNodeView = Array.from(this.children).some((el) => el.getAttribute('slot') === 'nodeView');
    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 = {
      toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this.toolbar;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
      bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this.bubbleMenu;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
      floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
        const tpl = this.floatingMenu;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
      linkEditor: (container: HTMLElement, scope: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): ReactivePortalHandle => {
        const tpl = this.linkEditor;
        if (typeof tpl !== 'function') return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-linkEditor', '2aeee876');
        const renderScope = (s: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): void => {
          render(tpl(s), container);
        };
        renderScope(scope);
        this._portalContainers.add(container);
        return {
          update: (s: { editor: unknown; href: unknown; attrs: unknown; setLink: unknown; unsetLink: unknown; close: unknown }): void => renderScope(s),
          dispose: (): void => {
            render(nothing, container);
            this._portalContainers.delete(container);
          },
        };
      },
      nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
        const tpl = this.nodeView;
        if (typeof tpl !== 'function') return { update() {}, dispose() {} };
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
        const renderScope = (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => {
          render(tpl(s), container);
        };
        renderScope(scope);
        this._portalContainers.add(container);
        return {
          update: (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => renderScope(s),
          dispose: (): void => {
            render(nothing, container);
            this._portalContainers.delete(container);
          },
        };
      },
    };

    this._disconnectCleanups.push((() => {
      this.toolbarDispose?.();
      this.toolbarDispose = null;
      this.bubbleMenuDispose?.();
      this.bubbleMenuDispose = null;
      this.floatingMenuDispose?.();
      this.floatingMenuDispose = null;
      this.linkEditorHandle?.dispose();
      this.linkEditorHandle = null;
      this.linkEditorEl = null;
      this.linkInputEl = null;
      this.editor?.destroy();
    }));

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.html)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
      if (!this.editor) return;
      if (v === this.lastHtml) return;
      this.lastHtml = v;
      this.editor.commands.setContent(v, {
        emitUpdate: false
      });
      this.refreshActive();
      this.refreshCount();
      this.refreshLink();
    })(__watchVal); }); }));

    this.lastHtml = this.html;

    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    // Register the reactive node-view nodes ONLY when the consumer fills the
    // `nodeView` slot AND supplies one or more `nodeSpecs` (D-05 — BOTH halves
    // required). A stock <TipTap> with no nodeSpecs (or an unfilled slot) adds
    // NO custom nodes — zero overhead, no consumer-node-shaped parse rules
    // registered, no unused $portals.nodeView reference fired. $props.nodeSpecs is
    // read ONCE here (setup-once — NOT a $watch); $portals.nodeView is captured
    // here inside the mount body and passed into the node factory, keeping the
    // reference scoped to the mount lifecycle (the toolbar-slot discipline).
    const nodeViewExtensions = this.nodeView !== undefined && this.nodeSpecs.length ? this.makeNodeViewExtensions(portals.nodeView, this.nodeSpecs) : [];

    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    // Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
    // (setup-once, like content/editable/autofocus — no reactivity required). The
    // Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
    // adds class `is-editor-empty` + a `data-placeholder` attribute to the first
    // empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
    // (in the style block) paints the ghost text. Empty placeholder = no extension.
    const placeholderExtensions = this.placeholder ? [Placeholder.configure({
      placeholder: this.placeholder
    })] : [];

    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    // Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
    // Floating-UI menu extension needs its host `element` at construction time. Each
    // menu's host element is created imperatively (the nodeView discipline — the
    // engine owns positioning; the consumer fragment is portalled in AFTER mount).
    // An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
    //
    // The host elements are created up front (when filled) so they're captured into
    // the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
    // portal mount; the extension list is then assembled by conditional SPREAD (NOT
    // `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
    // infers `any[]` — a bare `const x = []` would infer `never[]` and reject
    // `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
    if (this.bubbleMenu !== undefined) {
      this.bubbleMenuEl = document.createElement('div');
      this.bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
    }
    if (this.floatingMenu !== undefined) {
      this.floatingMenuEl = document.createElement('div');
      this.floatingMenuEl.className = 'rozie-tiptap-floating-menu';
    }
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    // Link editor (#2) host — a dedicated bubble-menu surface, orthogonal to the
    // general `bubbleMenu` slot. Created imperatively (bubbleMenuEl discipline).
    // ALWAYS created (not gated on editable at mount): editability is a REACTIVE prop
    // ($watch(editable) → setEditable), so SHOWING is gated on `editor.isEditable` in
    // the link-editor shouldShow below — a live check that follows a runtime toggle.
    // This closes both directions of the mount-time-gate bug: a doc mounted readonly
    // that later becomes editable gets a working link editor, and a doc toggled TO
    // readonly can no longer be link-edited (isEditable false → never shows, so no
    // Apply/Remove on a read-only document).
    this.linkEditorEl = document.createElement('div');
    this.linkEditorEl.className = 'rozie-tiptap-link-editor';
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    // Each BubbleMenu instance REQUIRES a unique pluginKey (REQ-41) so the two
    // Floating-UI plugins (the general bubbleMenu + the link editor) don't collide.
    // The general bubbleMenu's `shouldShow` is the consumer-controllable predicate
    // ($props.bubbleMenuShouldShow, #4) when provided, else the extension default
    // (non-empty text selection). The link editor's shouldShow is link-aware: show
    // on a link (edit) OR when the toolbar Link button set openFlag (create) — NARROW
    // by design so it never fires on a bare selection and collide with the general one.
    const menuExtensions = [...(this.bubbleMenuEl ? [BubbleMenu.configure({
      pluginKey: 'rozieBubbleMenu',
      element: this.bubbleMenuEl,
      ...(this.bubbleMenuShouldShow ? {
        shouldShow: this.bubbleMenuShouldShow
      } : {})
    })] : []), ...(this.floatingMenuEl ? [FloatingMenu.configure({
      element: this.floatingMenuEl
    })] : []), ...(this.linkEditorEl ? [BubbleMenu.configure({
      pluginKey: 'rozieLinkEditor',
      element: this.linkEditorEl,
      // `editor.isEditable` gates the whole surface reactively (readonly ⇒ never
      // shows). NARROW otherwise: show on a link (edit) OR when the toolbar Link
      // button set openFlag (create) — never on a bare selection.
      shouldShow: ({
        editor
      }: any) => editor.isEditable && (editor.isActive('link') || this.openFlag)
    })] : [])];

    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    // Image-upload hook (ask D). Setup-once, gated on $props.uploadImage — read
    // ONCE here (not a $watch — mirrors autofocus/placeholder/nodeSpecs). When
    // absent: no Image extension, no paste/drop handlers (zero overhead, the
    // unfilled-slot discipline). Conditional SPREAD (not `const x = []; x.push`)
    // for the same never[]-inference reason as placeholderExtensions/nodeViewExtensions.
    const imageExtensions = this.uploadImage ? [Image] : [];

    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    // Character/word count (D-01..D-03). Gated on maxLength being set OR the
    // `count` slot being filled — a stock <TipTap> with neither registers NO
    // CharacterCount extension (zero overhead, no VR drift). `limit` is ONLY
    // configured when BOTH enforceMaxLength is true AND maxLength is set (hard
    // cap); otherwise CharacterCount tracks with no limit (soft — overflow
    // allowed, surfaced via the `over` state). Setup-once, read here (NOT a
    // $watch). Conditional SPREAD (not `const x = []; x.push`) for the same
    // never[]-inference reason as placeholderExtensions/imageExtensions.
    const needsCount = this.maxLength != null || this._hasSlotCount || this.count !== undefined;
    const characterCountExtensions = needsCount ? [CharacterCount.configure(this.enforceMaxLength && this.maxLength != null ? {
      limit: this.maxLength
    } : {})] : [];

    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    // uploadHandlers — ProseMirror `editorProps` paste/drop fallbacks (D-04).
    // A SHALLOW gated reference object — `{}` (no-op) when $props.uploadImage
    // is unset, else shorthand-referencing the top-level handlePaste/handleDrop
    // functions declared above (see their doc comment for why they live at the
    // top level rather than as closures nested in this ternary).
    const uploadHandlers = this.uploadImage ? {
      handlePaste: this.handlePaste,
      handleDrop: this.handleDrop
    } : {};
    this.editor = new Editor({
      element: this._refEditorEl,
      content: this.html,
      editable: this.editable,
      autofocus: this.autofocus,
      // StarterKit first (config-disabled per the collision scan below); the
      // Placeholder ext next; the reactive node-view nodes next; consumer
      // extensions LAST so they win (TipTap applies later-registered extensions
      // over earlier ones for the same node/mark) — and the whole array is
      // name-deduped keeping the LAST occurrence as a safety net (D-03) on top
      // of the config-level auto-disable (D-02), which is what actually silences
      // StarterKit's internal same-named extension (e.g. its bundled `Link`).
      extensions: this.dedupeExtensionsByName([StarterKit.configure(this.buildStarterKitConfig(this.starterKit, this.extensions)), ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...imageExtensions, ...characterCountExtensions, ...this.extensions]),
      editorProps: {
        attributes: {
          'aria-label': this.ariaLabel,
          ...(this.editorClass ? {
            class: this.editorClass
          } : {}),
          ...(this.placeholder ? {
            'data-placeholder': this.placeholder,
            'aria-placeholder': this.placeholder
          } : {})
        },
        // uploadImage paste/drop fallbacks (D-04) — spread BEFORE the consumer's
        // own editorProps so a consumer-supplied handlePaste/handleDrop wins.
        // `{}` (no-op) when $props.uploadImage is unset.
        ...uploadHandlers,
        // Consumer editorProps spread LAST — full ProseMirror editorProps control
        // (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
        ...this.editorProps
      },
      onUpdate: ({
        editor
      }: any) => {
        const next = editor.getHTML();
        this.lastHtml = next;
        // Round-trip guard — see CodeMirror/Flatpickr for the same shape.
        if (next !== this.html) this._htmlControllable.write(next);
        this.refreshCount();
        this.refreshLink();
        this.dispatchEvent(new CustomEvent("update", {
          detail: next,
          bubbles: true,
          composed: true
        }));
      },
      onSelectionUpdate: () => {
        this.refreshActive();
        this.refreshLink();
        this.dispatchEvent(new CustomEvent("selection-update", {
          detail: undefined,
          bubbles: true,
          composed: true
        }));
      },
      onFocus: () => this.dispatchEvent(new CustomEvent("focus", {
        detail: undefined,
        bubbles: true,
        composed: true
      })),
      onBlur: ({
        event
      }: any) => {
        // Clear the create-mode latch when focus truly leaves the editor + its link
        // surface — but NOT when it moves INTO the link editor host (clicking the URL
        // input blurs the editor; the buttons are already covered by their keepFocus
        // mousedown). Without this, openFlag stays true after the user dismisses the
        // create affordance by clicking away, so the editor spuriously re-surfaces on
        // the next unrelated selection.
        const to = event && event.relatedTarget;
        if (!(to instanceof Node && this.linkEditorEl && this.linkEditorEl.contains(to))) this.openFlag = false;
        this.dispatchEvent(new CustomEvent("blur", {
          detail: undefined,
          bubbles: true,
          composed: true
        }));
      }
    });
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();

    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    // `toolbar` portal slot — when the consumer fills it, mount their toolbar
    // fragment into the engine-adjacent host node, handing them the live editor
    // (their buttons call editor.chain().focus()…run()). $portals.toolbar is
    // referenced ONLY here inside $onMount (the per-target portal helper is scoped
    // to the mount lifecycle — a top-level reference would fail the bundled-leaf
    // strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
    // r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
    if (this.toolbar !== undefined && this._refToolbarEl) {
      this.toolbarDispose = portals.toolbar(this._refToolbarEl, {
        editor: this.editor
      });
    }

    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    // `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
    // fragment into the engine-owned (imperatively-created) host element handed to
    // the Floating-UI menu extension, with the live editor in scope (their buttons
    // call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
    // / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
    // strict-typecheck discipline). The element is created above only when the slot
    // is filled, so each portal fires exactly when its slot exists.
    if (this.bubbleMenuEl) {
      this.bubbleMenuDispose = portals.bubbleMenu(this.bubbleMenuEl, {
        editor: this.editor
      });
    }
    if (this.floatingMenuEl) {
      this.floatingMenuDispose = portals.floatingMenu(this.floatingMenuEl, {
        editor: this.editor
      });
    }

    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    // Link editor (#2) — mount the surface into its engine-managed host. When the
    // consumer fills `#linkEditor`, the REACTIVE portal renders their fragment
    // (re-rendered in place by refreshLink()'s handle.update() — Spike 016 proved
    // this survives the bubble-menu extension's detach-reattach). Otherwise the
    // component's own default form is built imperatively into the same host.
    // $portals.linkEditor is referenced ONLY here inside $onMount (portal discipline).
    if (this.linkEditorEl) {
      if (this.linkEditor !== undefined) {
        // Read the initial link attrs straight off the live editor (NOT
        // `$data.linkState`, written by the refreshLink() call above in this
        // same tick) — the same React stale-read avoidance as buildLinkScope's
        // other call site.
        const initialLinkAttrs = this.editor.getAttributes('link');
        this.linkEditorHandle = portals.linkEditor(this.linkEditorEl, this.buildLinkScope(initialLinkAttrs.href || '', initialLinkAttrs));
      } else {
        this.buildDefaultLinkEditor(this.linkEditorEl);
        // Prefill correction (D-04): the refreshLink() call above (right after
        // `new Editor(...)`) already latched lastLinkKey — linkInputEl didn't
        // exist yet at that point, so every LATER refreshLink() for the same
        // link early-returns, leaving the just-created input empty even when the
        // caret starts inside a link. Seed it directly from the LIVE editor
        // (`editor.getAttributes('link')`), NOT `$data.linkState` — reading a
        // $data key immediately after refreshLink() just wrote it hits the
        // React setState-is-async stale-read trap (the same write-then-read-in-
        // one-handler class ROZ138 warns about elsewhere in this file), since
        // $data.linkState was written by the refreshLink() call directly above.
        // `editor` is a plain instance handle, not reactive state, so reading it
        // straight off the engine is synchronous and target-uniform. A no-link
        // mount leaves this the empty string (unchanged).
        if (this.linkInputEl) this.linkInputEl.value = this.editor.getAttributes('link').href || '';
      }
    }
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (this.__rozieFirstUpdateDone && (changedProperties.has('editable'))) { const __watchVal = (() => this.editable)(); ((v: any) => this.editor?.setEditable(v, false))(__watchVal); }
    this.__rozieFirstUpdateDone = true;
  }

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

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

  render() {
    return html`
<div class="${Object.entries({ "rozie-tiptap": true, 'is-readonly': !this.editable }).filter(([, v]) => v).map(([k]) => k).join(' ')}" data-rozie-s-2aeee876>
  
  ${this.editable && !(this.toolbar !== undefined) ? html`<div class="rozie-tiptap-toolbar" data-rozie-s-2aeee876>
    <button class="${Object.entries({ active: this._active.value.bold }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Bold" @click=${this.toggleBold} data-rozie-s-2aeee876><strong data-rozie-s-2aeee876>B</strong></button>
    <button class="${Object.entries({ active: this._active.value.italic }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Italic" @click=${this.toggleItalic} data-rozie-s-2aeee876><em data-rozie-s-2aeee876>I</em></button>
    <span class="sep" data-rozie-s-2aeee876></span>
    <button class="${Object.entries({ active: this._active.value.h1 }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Heading 1" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.toggleHeading(1); }} data-rozie-s-2aeee876>H1</button>
    <button class="${Object.entries({ active: this._active.value.h2 }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Heading 2" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.toggleHeading(2); }} data-rozie-s-2aeee876>H2</button>
    <span class="sep" data-rozie-s-2aeee876></span>
    <button class="${Object.entries({ active: this._active.value.bulletList }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Bullet list" @click=${this.toggleBulletList} data-rozie-s-2aeee876>• List</button>
    <button class="${Object.entries({ active: this._active.value.underline }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Underline" @click=${this.toggleUnderline} data-rozie-s-2aeee876><u data-rozie-s-2aeee876>U</u></button>
    <button class="${Object.entries({ active: this._active.value.orderedList }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Ordered list" @click=${this.toggleOrderedList} data-rozie-s-2aeee876>1. List</button>
    <span class="sep" data-rozie-s-2aeee876></span>
    <button class="${Object.entries({ active: this._active.value.link }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Link" @click=${this.openLinkEditor} data-rozie-s-2aeee876>Link</button>
    <span class="sep" data-rozie-s-2aeee876></span>
    <button type="button" aria-label="Undo" @click=${this.undo} data-rozie-s-2aeee876>↺</button>
    <button type="button" aria-label="Redo" @click=${this.redo} data-rozie-s-2aeee876>↻</button>
  </div>` : nothing}${this.editable && this.toolbar !== undefined ? html`<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" data-rozie-ref="toolbarEl" data-rozie-s-2aeee876></div>` : nothing}<div class="rozie-tiptap-content" data-placeholder=${this.placeholder} data-rozie-ref="editorEl" data-rozie-s-2aeee876></div>
  
  ${this.maxLength != null || this._hasSlotCount || this.count !== undefined ? html`<div class="rozie-tiptap-count" data-rozie-s-2aeee876>
    ${this.count !== undefined ? this.count({characters: this._count.value.characters, words: this._count.value.words, maxLength: this.maxLength, over: this.maxLength != null && this._count.value.characters > this.maxLength}) : html`<slot name="count" data-rozie-params=${(() => { try { return JSON.stringify({characters: this._count.value.characters, words: this._count.value.words, maxLength: this.maxLength, over: this.maxLength != null && this._count.value.characters > this.maxLength}); } catch { return '{}'; } })()}>
      <span class="${Object.entries({ "rozie-tiptap-count-value": true, over: this.maxLength != null && this._count.value.characters > this.maxLength }).filter(([, v]) => v).map(([k]) => k).join(' ')}" data-rozie-s-2aeee876>${rozieDisplay(this._count.value.characters)} / ${this.maxLength}</span>
    </slot>`}
  </div>` : nothing}</div>

<slot name="toolbar"></slot>

<slot name="bubbleMenu"></slot>
<slot name="floatingMenu"></slot>

<slot name="linkEditor"></slot>

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

  editor: any = null;

  lastHtml: any = null;

  toolbarDispose: any = null;

  bubbleMenuEl: any = null;

  bubbleMenuDispose: any = null;

  floatingMenuEl: any = null;

  floatingMenuDispose: any = null;

  linkEditorEl: any = null;

  linkEditorHandle: any = null;

  linkInputEl: any = null;

  openFlag = false;

  lastLinkKey: any = null;

  refreshActive = () => {
  if (!this.editor) return;
  this._active.value = {
    bold: this.editor.isActive('bold'),
    italic: this.editor.isActive('italic'),
    h1: this.editor.isActive('heading', {
      level: 1
    }),
    h2: this.editor.isActive('heading', {
      level: 2
    }),
    bulletList: this.editor.isActive('bulletList'),
    underline: this.editor.isActive('underline'),
    orderedList: this.editor.isActive('orderedList'),
    link: this.editor.isActive('link')
  };
};

  applyLink = (attrs: any) => {
  // A link mark requires a non-empty href — ignore an empty Apply (built-in form)
  // or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
  if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return;
  this.editor?.chain().focus().extendMarkRange('link').setLink(attrs).run();
  this.openFlag = false;
};

  removeLink = () => {
  this.editor?.chain().focus().extendMarkRange('link').unsetLink().run();
  this.openFlag = false;
};

  forceMenuRecheck = () => {
  if (!this.editor) return;
  const visible = this.editor.isEditable && (this.editor.isActive('link') || this.openFlag);
  this.editor.view.dispatch(this.editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'));
};

  closeLink = () => {
  this.openFlag = false;
  // "Cancel" = discard the unsaved edit: revert the built-in form's input to the
  // current link href. The surface itself is link-anchored (like Google Docs) — it
  // stays while the caret is on a link and hides once openFlag is clear and the
  // caret is off any link (or the doc is not editable).
  if (this.linkInputEl) this.linkInputEl.value = this._linkState.value.href;
  this.editor?.commands.focus();
  this.forceMenuRecheck();
};

  buildLinkScope = (href: any, attrs: any) => ({
  editor: this.editor,
  href,
  attrs,
  setLink: this.applyLink,
  unsetLink: this.removeLink,
  close: this.closeLink
});

  refreshLink = () => {
  if (!this.editor) return;
  const a = this.editor.getAttributes('link');
  const href = a.href || '';
  // Early-return when the link mark is unchanged — collapses the twice-per-keystroke
  // onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
  // reactive-portal re-render of the #linkEditor fragment on caret moves that don't
  // change the link).
  const key = href + '' + JSON.stringify(a);
  if (key === this.lastLinkKey) return;
  this.lastLinkKey = key;
  this._linkState.value = {
    href,
    attrs: a
  };
  if (this.linkEditorHandle) {
    this.linkEditorHandle.update(this.buildLinkScope(href, a));
  } else if (this.linkInputEl && !this.linkInputEl.matches(':focus')) {
    // `matches(':focus')` (NOT `document.activeElement === linkInputEl`) so the "is
    // the user typing in this input?" guard holds inside a shadow root — on the Lit
    // target document.activeElement is the shadow HOST, so a document.activeElement
    // check would always miss and stomp the user's in-progress URL. `:focus` is
    // per-element and shadow-boundary-agnostic.
    this.linkInputEl.value = href;
  }
};

  openLinkEditor = () => {
  this.openFlag = true;
  this.editor?.commands.focus();
  this.refreshLink();
  this.forceMenuRecheck();
};

  buildDefaultLinkEditor = (el: any) => {
  const input = document.createElement('input');
  input.type = 'text';
  input.className = 'rozie-tiptap-link-input';
  input.placeholder = 'https://…';
  const apply = document.createElement('button');
  apply.type = 'button';
  apply.className = 'rozie-tiptap-link-apply';
  apply.textContent = 'Apply';
  const remove = document.createElement('button');
  remove.type = 'button';
  remove.className = 'rozie-tiptap-link-remove';
  remove.textContent = 'Remove';
  const cancel = document.createElement('button');
  cancel.type = 'button';
  cancel.className = 'rozie-tiptap-link-cancel';
  cancel.textContent = 'Cancel';
  // Keep the caret/selection in the document when a control is pressed (a plain
  // click would blur the editor and collapse the selection before the command runs).
  const keepFocus = (e: any) => e.preventDefault();
  for (const b of [apply, remove, cancel] as any) b.addEventListener('mousedown', keepFocus);
  apply.addEventListener('click', () => this.applyLink({
    href: input.value
  }));
  remove.addEventListener('click', this.removeLink);
  cancel.addEventListener('click', this.closeLink);
  input.addEventListener('keydown', (e: any) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.applyLink({
        href: input.value
      });
    } else if (e.key === 'Escape') {
      e.preventDefault();
      this.closeLink();
    }
  });
  el.appendChild(input);
  el.appendChild(apply);
  el.appendChild(remove);
  el.appendChild(cancel);
  this.linkInputEl = input;
};

  refreshCount = () => {
  if (!this.editor) return;
  const storage = this.editor.storage.characterCount;
  this._count.value = {
    characters: storage ? storage.characters() : this.editor.getText().length,
    words: storage ? storage.words() : this.editor.getText().split(/\s+/).filter(Boolean).length
  };
};

  STARTERKIT_COLLISION_MAP = {
  bold: 'bold',
  italic: 'italic',
  strike: 'strike',
  code: 'code',
  heading: 'heading',
  paragraph: 'paragraph',
  blockquote: 'blockquote',
  codeBlock: 'codeBlock',
  hardBreak: 'hardBreak',
  horizontalRule: 'horizontalRule',
  bulletList: 'bulletList',
  orderedList: 'orderedList',
  listItem: 'listItem',
  link: 'link',
  underline: 'underline',
  undoRedo: 'undoRedo',
  history: 'undoRedo'
};

  buildStarterKitConfig = (userConfig: any, exts: any) => {
  const effective = {
    ...userConfig
  };
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    if (typeof name !== 'string') continue;
    const optionKey = this.STARTERKIT_COLLISION_MAP[name];
    if (optionKey && !(optionKey in effective)) effective[optionKey] = false;
  }
  return effective;
};

  dedupeExtensionsByName = (exts: any) => {
  const byKey = new Map();
  let anonSeq = 0;
  for (const ext of exts as any) {
    const name = ext && typeof ext === 'object' ? ext.name : undefined;
    const key = typeof name === 'string' ? name : `__rozie_anon_${anonSeq++}`;
    byKey.set(key, ext);
  }
  return [...byKey.values()];
};

  makeNodeView = (nv: any, spec: any) => (props: any) => {
  const {
    node,
    getPos,
    editor: ed
  } = props;
  // hasContentDOM derives from the spec, not a bare boolean: an editable node
  // is one that is NOT an atom and declares `content` (e.g. 'inline*').
  const hasContentDOM = !spec.atom && !!spec.content;
  // engine-owned outer host the consumer fragment mounts into.
  const dom = document.createElement(hasContentDOM ? 'div' : 'span');
  dom.className = hasContentDOM ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
  // EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
  // into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
  const contentDOM = hasContentDOM ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
  if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
  const updateAttributes = (attrs: any) => {
    if (typeof getPos !== 'function') return;
    const pos = getPos();
    if (pos == null) return;
    ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
      ...node.attrs,
      ...attrs
    }));
  };
  const buildScope = (n: any, selected: any) => ({
    node: n,
    selected,
    updateAttributes,
    getPos,
    editor: ed,
    ...(contentDOM ? {
      contentDOM
    } : {})
  });

  // Reactive handle — { update, dispose }. The fragment mounts ONCE; every
  // engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
  const handle = nv(dom, buildScope(node, false));

  // contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
  // consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
  // ProseMirror manages `contentDOM` and renders the node's editable children
  // INTO it, so `contentDOM` must live inside the visible hole. The fragment is
  // rendered into `dom` by the per-target reactive portal — synchronously on
  // React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
  // Angular (REQ-23). A query-after-render graft (retried across a microtask +
  // a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
  // the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
  // and the framework never reconciles it away (the hole carries no child binding).
  const graftContentDOM = (attempt: any) => {
    if (!contentDOM) return;
    const hole = dom.querySelector('[data-rozie-hole]');
    if (hole) {
      if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
      return;
    }
    if (attempt < 5) {
      if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
    }
  };
  graftContentDOM(0);

  // After a reactive re-render (chrome update), re-graft so a fragment that
  // recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
  // (REQ-24 — the editable subtree survives every chrome update).
  const updateInPlace = (n: any, selected: any) => {
    handle.update(buildScope(n, selected));
    if (contentDOM) graftContentDOM(0);
  };
  return {
    dom,
    ...(contentDOM ? {
      contentDOM
    } : {}),
    // attr / content change for THIS node → re-render the fragment in place,
    // keep the view (return true). The new node identity is forwarded so the
    // fragment reads fresh node.attrs (REQ-26).
    update(nextNode: any) {
      if (nextNode.type !== node.type) return false;
      updateInPlace(nextNode, false);
      return true;
    },
    // NodeSelection enters/leaves the node → toggle `selected` in scope so the
    // chip's selected styling is pure engine-driven reactive `update`.
    selectNode() {
      updateInPlace(node, true);
    },
    deselectNode() {
      updateInPlace(node, false);
    },
    destroy() {
      handle.dispose();
    }
  };
};

  parseTagSelector = (selector: any) => {
  const raw = typeof selector === 'string' ? selector : '';
  const elMatch = raw.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
  const el = elMatch ? elMatch[0] : raw || 'span';
  const attrMatch = raw.match(/\[([^\]=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/);
  if (!attrMatch) return {
    el,
    attr: null,
    value: ''
  };
  const attr = (attrMatch[1] ?? '').trim();
  const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
  return {
    el,
    attr,
    value
  };
};

  makeNodeViewExtensions = (nv: any, specs: any) => specs.map((spec: any) => {
  // hasContentDOM decides the renderHTML hole: an editable (non-atom,
  // content-bearing) node gets a trailing `0` content hole; a leaf/atom node
  // must NOT (ProseMirror's DOMSerializer throws "Content hole not allowed in
  // a leaf node spec" otherwise).
  const hasContentDOM = !spec.atom && !!spec.content;
  const firstTag = Array.isArray(spec.tag) ? spec.tag[0] : spec.tag;
  const {
    el,
    attr,
    value
  } = this.parseTagSelector(firstTag);
  return Node.create({
    name: spec.name,
    group: spec.group ?? 'block',
    inline: spec.inline ?? false,
    atom: spec.atom ?? false,
    selectable: spec.selectable ?? true,
    defining: spec.defining ?? false,
    ...(spec.content ? {
      content: spec.content
    } : {}),
    addAttributes: () => spec.attrs ?? {},
    parseHTML: () => (Array.isArray(spec.tag) ? spec.tag : [spec.tag]).map((t: any) => ({
      tag: t
    })),
    renderHTML: ({
      HTMLAttributes
    }: any) => hasContentDOM ? [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }, 0] : [el, {
      ...(attr ? {
        [attr]: value
      } : {}),
      ...HTMLAttributes
    }],
    addNodeView: () => this.makeNodeView(nv, spec)
  });
});

  findImageFile = (files: any) => {
  if (!files) return undefined;
  for (let i = 0; i < files.length; i++) {
    const f = files[i];
    if (f && typeof f.type === 'string' && f.type.indexOf('image/') === 0) return f;
  }
  return undefined;
};

  handlePaste(view: any, event: any, slice: any) {
    // Captured into a local (not repeated `$props.uploadImage` member reads) so
    // the null-check narrows the type on every target — including Lit, where
    // the Function prop lowers to a nullable function type and a bare
    // `$props.uploadImage(file)` call trips strict-null under bundled-leaf
    // typecheck (TS2721) even though this handler is only ever wired into
    // editorProps when uploadImage is truthy (belt-and-suspenders — the D-03
    // gate already guarantees this in practice).
    const upload = this.uploadImage;
    if (!upload) return false;
    const file = this.findImageFile(event.clipboardData ? event.clipboardData.files : undefined);
    if (!file) return false;
    event.preventDefault();
    upload(file).then((url: any) => {
      this.editor?.chain().focus().setImage({
        src: url
      }).run();
    }).catch(() => {});
    return true;
  }

  handleDrop(view: any, event: any, slice: any, moved: any) {
    if (moved) return false;
    // See handlePaste — local capture for the same cross-target null-narrowing.
    const upload = this.uploadImage;
    if (!upload) return false;
    const file = this.findImageFile(event.dataTransfer ? event.dataTransfer.files : undefined);
    if (!file) return false;
    event.preventDefault();
    const pos = view.posAtCoords({
      left: event.clientX,
      top: event.clientY
    });
    upload(file).then((url: any) => {
      const insertPos = pos ? pos.pos : this.editor ? this.editor.state.selection.head : 0;
      this.editor?.chain().focus().insertContentAt(insertPos, {
        type: 'image',
        attrs: {
          src: url
        }
      }).run();
    }).catch(() => {});
    return true;
  }

  getEditor() {
    return this.editor;
  }

  focusEditor() {
    this.editor?.commands.focus();
  }

  blurEditor() {
    this.editor?.commands.blur();
  }

  getHTML() {
    return this.editor ? this.editor.getHTML() : '';
  }

  getJSON() {
    return this.editor ? this.editor.getJSON() : null;
  }

  getText() {
    return this.editor ? this.editor.getText() : '';
  }

  setContent(next: any) {
    if (!this.editor) return;
    const v = next ?? '';
    if (v === this.lastHtml) return;
    this.lastHtml = v;
    this.editor.commands.setContent(v, {
      emitUpdate: false
    });
    this._htmlControllable.write(v);
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();
  }

  clearContent() {
    if (!this.editor) return;
    this.editor.commands.clearContent();
    this.lastHtml = this.editor.getHTML();
    this._htmlControllable.write(this.lastHtml);
    this.refreshActive();
    this.refreshCount();
    this.refreshLink();
  }

  toggleBold() {
    this.editor?.chain().focus().toggleBold().run();
    this.refreshActive();
  }

  toggleItalic() {
    this.editor?.chain().focus().toggleItalic().run();
    this.refreshActive();
  }

  toggleHeading(level: any) {
    this.editor?.chain().focus().toggleHeading({
      level: level ?? 1
    }).run();
    this.refreshActive();
  }

  toggleBulletList() {
    this.editor?.chain().focus().toggleBulletList().run();
    this.refreshActive();
  }

  toggleUnderline() {
    this.editor?.chain().focus().toggleUnderline().run();
    this.refreshActive();
  }

  toggleOrderedList() {
    this.editor?.chain().focus().toggleOrderedList().run();
    this.refreshActive();
  }

  undo() {
    this.editor?.chain().focus().undo().run();
    this.refreshActive();
  }

  redo() {
    this.editor?.chain().focus().redo().run();
    this.refreshActive();
  }

  chain() {
    return this.editor ? this.editor.chain().focus() : null;
  }

  isActive(name: any, attrs: any) {
    return this.editor ? this.editor.isActive(name, attrs) : false;
  }

  can() {
    return this.editor ? this.editor.can() : null;
  }

  isEmpty() {
    return this.editor ? this.editor.isEmpty : true;
  }

  getCharacterCount() {
    if (!this.editor) return 0;
    return this.editor.storage.characterCount ? this.editor.storage.characterCount.characters() : this.editor.getText().length;
  }

  getWordCount() {
    if (!this.editor) return 0;
    return this.editor.storage.characterCount ? this.editor.storage.characterCount.words() : this.editor.getText().split(/\s+/).filter(Boolean).length;
  }

  setLink(attrs: any) {
    this.applyLink(attrs);
  }

  unsetLink() {
    this.removeLink();
  }

  get html(): string { return this._htmlControllable.read(); }
  set html(v: string) { this._htmlControllable.notifyPropertyWrite(v); }
}

injectGlobalStyles('rozie-tip-tap-9bcd6684-global', `
.rozie-tiptap-content .is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    color: var(--rozie-tiptap-placeholder-color, rgba(0, 0, 0, 0.4));
    float: left;
    height: 0;
    pointer-events: none;
  }
.rozie-tiptap-link-editor {
    display: flex;
    align-items: center;
    gap: var(--rozie-tiptap-link-gap, 0.25rem);
    padding: var(--rozie-tiptap-link-padding, 0.3125rem 0.375rem);
    background: var(--rozie-tiptap-link-bg, #1a1a1a);
    border: var(--rozie-tiptap-link-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rozie-tiptap-link-radius, 6px);
    box-shadow: var(--rozie-tiptap-link-shadow, 0 4px 16px rgba(0, 0, 0, 0.25));
  }
.rozie-tiptap-link-input {
    font: inherit;
    font-size: var(--rozie-tiptap-link-input-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-input-padding, 0.1875rem 0.375rem);
    min-width: var(--rozie-tiptap-link-input-min-width, 11rem);
    border: var(--rozie-tiptap-link-input-border, 1px solid #444);
    border-radius: var(--rozie-tiptap-link-input-radius, 4px);
    background: var(--rozie-tiptap-link-input-bg, #fff);
    color: var(--rozie-tiptap-link-input-color, #000);
  }
.rozie-tiptap-link-editor button {
    font: inherit;
    font-size: var(--rozie-tiptap-link-button-font-size, 0.8125rem);
    padding: var(--rozie-tiptap-link-button-padding, 0.1875rem 0.5rem);
    border: var(--rozie-tiptap-link-button-border, 1px solid transparent);
    border-radius: var(--rozie-tiptap-link-button-radius, 4px);
    background: var(--rozie-tiptap-link-button-bg, rgba(255, 255, 255, 0.12));
    color: var(--rozie-tiptap-link-button-color, #fff);
    cursor: pointer;
  }
.rozie-tiptap-link-editor button:hover {
    background: var(--rozie-tiptap-link-button-hover-bg, rgba(255, 255, 255, 0.22));
  }
.rozie-tiptap-link-editor .rozie-tiptap-link-remove {
    color: var(--rozie-tiptap-link-remove-color, #ff9b9b);
  }
`);

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

See also

  • TipTap — showcase & API — install, per-framework quick starts, the events, the imperative handle, and the toolbar / bubble-menu / floating-menu / node-view slots.
  • TipTap libraries comparison — how @rozie-ui/tiptap stacks up against the per-framework wrappers.

Pre-1.0 — APIs may change between minor versions.