Skip to content

Lexical — live demo ​

This is the real @rozie-ui/lexical-vue package running on this page (VitePress is itself a Vue app) — built from this repo's workspace, not npm: no @rozie-ui/lexical-* package has published yet. Type in the editor, select some text and hit the toolbar buttons — Bold, Italic, • List, and Link — and watch each button light up as the caret moves through matching formatting. The same Lexical components, with the same API, ship for React, Vue, Svelte, Angular, Solid, and Lit.

The editor is composed the way you'd compose it in your own app: a <LexicalEditor> shell wrapping a <Toolbar /> plus the HistoryPlugin / ListPlugin / LinkPlugin children. Each child $injects the same shared editor instance the shell provides — no editor handle is prop-drilled. The shell registers the RichText baseline itself, so formatting and undo/redo work out of the box; the list and link buttons take effect because their plugins are nested. See the full API for the composition model, the plugin list, and the $inject contract for custom children.

What ships for each framework ​

The editor shell is one .rozie file:

html
<!--
  LexicalEditor.rozie — the editor SHELL for @rozie-ui/lexical (D-04). The
  substrate every later wave builds on: it creates a Lexical editor in $onMount,
  binds it to a contenteditable host via $refs + setRootElement, registers the
  RichText baseline, and $provides the live instance so plugin/toolbar components
  (waves 2–4) $inject the SAME identity and drive it imperatively (spike 010).

  D-05 / REQ-37 (HARD, spike 013): ALL Lexical `$`-API is authored in the
  NAMESPACE-import form — `import * as lexical from 'lexical'; lexical.$getRoot()`.
  Named `$`-imports (`import { $getRoot } from 'lexical'`) break the Svelte
  compiler (`dollar_prefix_invalid`); the namespace form makes every `$`-call a
  PROPERTY access, which Svelte does not reserve. This is the ONE cross-target-safe
  form and is gated by scripts/compile-lexical-check.mjs.

  The shell registers the full v1.0 node CLASS set up front (Lexical requires all
  node classes declared at editor creation); the plugin components register only
  BEHAVIOR. MentionNode is added to `nodes` by plan 76-04.
-->
<rozie name="LexicalEditor">

<props>
{
  nodes: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).',
    },
  },
  namespace: {
    type: String,
    default: '',
    docs: {
      description:
        "The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.",
    },
  },
  ariaLabel: {
    type: String,
    default: null,
    docs: {
      description:
        'Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.',
    },
  },
  theme: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        'Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.',
    },
  },
}
</props>

<script>
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical'
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text'
import { ListNode, ListItemNode } from '@lexical/list'
import { LinkNode, AutoLinkNode } from '@lexical/link'
import { mergeRegister } from '@lexical/utils'
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode'
import { mountDecorators } from './mountDecorators'

// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor = null

// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)
$provide('rozie-lexical-editor', {
  get instance() {
    return editor
  },
})

$onMount(() => {
  editor = lexical.createEditor({
    namespace: $props.namespace || 'rozie-lexical',
    // The full v1.0 node CLASS set is declared here (Lexical requires all node
    // classes up front); consumer `nodes` are composed LAST so they win.
    nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...$props.nodes],
    // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
    onError: (e) => {
      throw e
    },
    theme: $props.theme,
  })

  // Bind the editor to the authored contenteditable host.
  editor.setRootElement($refs.rootEl)

  // Register the RichText baseline AND wire the per-target @mention decorator bridge
  // (D-06): mountDecorators returns an unregister fn, folded into the same
  // mergeRegister so the decorator listener tears down with everything else. Plugin
  // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
  // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
  const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor))

  // Seed an empty paragraph so the caret has a block to land in when the document
  // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
  editor.update(() => {
    const root = lexical.$getRoot()
    if (root.getFirstChild() === null) {
      const paragraph = lexical.$createParagraphNode()
      root.append(paragraph)
    }
  })

  // Teardown colocated in the $onMount return (D-04): unregister everything, then
  // null the instance so a late teardown read sees a defined value.
  return () => {
    cleanup()
    editor = null
  }
})
</script>

<template>
<div class="rozie-lexical">
  <!-- The single contenteditable host Lexical owns via setRootElement($refs.rootEl).
       :aria-label drops from the DOM when ariaLabel is null (rozieAttr nullish-drop). -->
  <div ref="rootEl" class="rozie-lexical-content" :contenteditable="true" :aria-label="$props.ariaLabel"></div>
  <!-- Compositional model: plugin/toolbar children mount here and $inject the
       $provided editor. Non-visual plugins render nothing; the toolbar (D-03)
       renders its own controls. Toolbar is a SEPARATE component — never inlined. -->
  <slot />
</div>
</template>

<style>
.rozie-lexical {
  display: block;
}
.rozie-lexical-content {
  min-height: 6rem;
  padding: 0.625rem 0.875rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 6px;
  outline: none;
  font: inherit;
}
.rozie-lexical-content:focus {
  border-color: #4f46e5;
}

/* The formatted nodes Lexical renders inside the contenteditable host (strong / em
   / ul / ol / headings) are ENGINE-rendered — they carry NO Rozie [data-rozie-s-*]
   scope attribute, so a plain scoped descendant rule would silently fail to match
   on the scoped-CSS targets (React/Solid/Lit). The nested :root { } escape hatch
   emits its children UNSCOPED/global on all targets, reaching the engine nodes.
   (Not :global() — that is a ROZ128 hard error; :root { nested } is canonical —
   the TipTap placeholder-ghost lesson, D-12.) */
:root {
  .rozie-lexical-content strong {
    font-weight: 700;
  }
  .rozie-lexical-content em {
    font-style: italic;
  }
  .rozie-lexical-content ul {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
  .rozie-lexical-content ol {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
  .rozie-lexical-content h1 {
    font-size: 1.5rem;
    margin: 0.5rem 0 0.375rem;
  }
  .rozie-lexical-content h2 {
    font-size: 1.25rem;
    margin: 0.5rem 0 0.375rem;
  }
  /* Default @mention chip styling (D-06 bridge output). Shipped BY THE COMPONENT
     (not left to the consumer) so the chip is styled on the Lit target too: the
     bridge renders the pill into a host span INSIDE this component's shadow root,
     which document/consumer CSS cannot cross — so the shadow-injected copy here is
     the only way a Lit consumer gets a styled chip. On the five light-DOM targets
     this is an identical-valued default under any consumer override (same four
     properties, same values), so it is a no-op there — the existing render is
     byte-identical. Themeable via `--rozie-lexical-mention-*`. */
  .rozie-lexical-content .rozie-mention {
    background: var(--rozie-lexical-mention-bg, #e0e7ff);
    border-radius: var(--rozie-lexical-mention-radius, 6px);
    padding: var(--rozie-lexical-mention-padding, 1px 6px);
    font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
  }
}
</style>

</rozie>

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

tsx
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieContext } from '@rozie/runtime-react';
import './LexicalEditor.css';
import './LexicalEditor.global.css';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';

// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).

interface LexicalEditorProps extends Omit<import('react').ComponentPropsWithoutRef<'div'>, 'nodes' | 'namespace' | 'ariaLabel' | 'theme' | 'children' | 'slots' | 'dangerouslySetInnerHTML'> {
  /**
   * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
   */
  nodes?: any[];
  /**
   * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
   */
  namespace?: string;
  /**
   * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
   */
  ariaLabel?: (string) | null;
  /**
   * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
   */
  theme?: Record<string, any>;
  children?: ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function LexicalEditor(_props: LexicalEditorProps): JSX.Element {
  const __ctx_rozie_lexical_editor = rozieContext("rozie-lexical-editor");
  const __defaultNodes = useState(() => (() => [])())[0];
  const __defaultTheme = useState(() => (() => ({}))())[0];
  const props: Omit<LexicalEditorProps, 'nodes' | 'namespace' | 'ariaLabel' | 'theme'> & { nodes: any[]; namespace: string; ariaLabel: (string) | null; theme: Record<string, any> } = {
    ..._props,
    nodes: _props.nodes ?? __defaultNodes,
    namespace: _props.namespace ?? '',
    ariaLabel: _props.ariaLabel ?? null,
    theme: _props.theme ?? __defaultTheme,
  };
  const attrs: Record<string, unknown> = (() => {
    const { nodes, namespace, ariaLabel, theme, ...rest } = _props as LexicalEditorProps & Record<string, unknown>;
    void nodes; void namespace; void ariaLabel; void theme;
    return rest;
  })();
  const editor = useRef<any>(null);
  const _namespaceRef = useRef(props.namespace);
  _namespaceRef.current = props.namespace;
  const _nodesRef = useRef(props.nodes);
  _nodesRef.current = props.nodes;
  const _themeRef = useRef(props.theme);
  _themeRef.current = props.theme;
  const rootEl = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    editor.current = lexical.createEditor({
      namespace: _namespaceRef.current || 'rozie-lexical',
      // The full v1.0 node CLASS set is declared here (Lexical requires all node
      // classes up front); consumer `nodes` are composed LAST so they win.
      nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ..._nodesRef.current],
      // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
      onError: (e: any) => {
        throw e;
      },
      theme: _themeRef.current
    });

    // Bind the editor to the authored contenteditable host.
    editor.current.setRootElement(rootEl.current!);

    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    const cleanup = mergeRegister(registerRichText(editor.current), mountDecorators(editor.current));

    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    editor.current.update(() => {
      const root = lexical.$getRoot();
      if (root.getFirstChild() === null) {
        const paragraph = lexical.$createParagraphNode();
        root.append(paragraph);
      }
    });

    // Teardown colocated in the $onMount return (D-04): unregister everything, then
    // null the instance so a late teardown read sees a defined value.
    return () => {
      cleanup();
      editor.current = null;
    };
  }, []);

  return (
    <__ctx_rozie_lexical_editor.Provider value={{
  get instance() {
    return editor.current;
  }
}}>
    <>
    <div {...attrs} className={clsx("rozie-lexical", (attrs.className as string | undefined))} data-rozie-s-f679124a="">
      
      <div ref={rootEl} className={"rozie-lexical-content"} contentEditable={true} aria-label={rozieAttr(props.ariaLabel)} data-rozie-s-f679124a="" />
      
      {(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
    </div>
    </>
    </__ctx_rozie_lexical_editor.Provider>
  );
}
vue
<template>

<div class="rozie-lexical" v-bind="$attrs">
  
  <div ref="rootElRef" class="rozie-lexical-content" :contenteditable="true" :aria-label="props.ariaLabel"></div>
  
  <slot></slot>
</div>

</template>

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

const props = withDefaults(
  defineProps<{
    /**
     * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
     */
    nodes?: any[];
    /**
     * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
     */
    namespace?: string;
    /**
     * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
     */
    ariaLabel?: string | null;
    /**
     * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
     */
    theme?: Record<string, any>;
  }>(),
  { nodes: () => [], namespace: '', ariaLabel: null, theme: () => ({}) }
);

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

const rootElRef = ref<HTMLElement>();

// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor: any = null;

// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)

provide('rozie-lexical-editor', {
  get instance() {
    return editor;
  }
});

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  editor = lexical.createEditor({
    namespace: props.namespace || 'rozie-lexical',
    // The full v1.0 node CLASS set is declared here (Lexical requires all node
    // classes up front); consumer `nodes` are composed LAST so they win.
    nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...props.nodes],
    // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
    onError: (e: any) => {
      throw e;
    },
    theme: props.theme
  });

  // Bind the editor to the authored contenteditable host.
  editor.setRootElement(rootElRef.value!);

  // Register the RichText baseline AND wire the per-target @mention decorator bridge
  // (D-06): mountDecorators returns an unregister fn, folded into the same
  // mergeRegister so the decorator listener tears down with everything else. Plugin
  // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
  // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
  const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));

  // Seed an empty paragraph so the caret has a block to land in when the document
  // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
  editor.update(() => {
    const root = lexical.$getRoot();
    if (root.getFirstChild() === null) {
      const paragraph = lexical.$createParagraphNode();
      root.append(paragraph);
    }
  });

  // Teardown colocated in the $onMount return (D-04): unregister everything, then
  // null the instance so a late teardown read sees a defined value.
  _cleanup_0 = () => {
    cleanup();
    editor = null;
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });
</script>

<style scoped>
.rozie-lexical {
  display: block;
}
.rozie-lexical-content {
  min-height: 6rem;
  padding: 0.625rem 0.875rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 6px;
  outline: none;
  font: inherit;
}
.rozie-lexical-content:focus {
  border-color: #4f46e5;
}
</style>

<style>
.rozie-lexical-content strong {
    font-weight: 700;
  }
.rozie-lexical-content em {
    font-style: italic;
  }
.rozie-lexical-content ul {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content ol {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content h1 {
    font-size: 1.5rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content h2 {
    font-size: 1.25rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content .rozie-mention {
    background: var(--rozie-lexical-mention-bg, #e0e7ff);
    border-radius: var(--rozie-lexical-mention-radius, 6px);
    padding: var(--rozie-lexical-mention-padding, 1px 6px);
    font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
  }
</style>
svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';

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

interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['div'], 'nodes' | 'namespace' | 'ariaLabel' | 'theme' | 'children' | 'snippets'> {
  /**
   * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
   */
  nodes?: any[];
  /**
   * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
   */
  namespace?: string;
  /**
   * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
   */
  ariaLabel?: (string) | null;
  /**
   * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
   */
  theme?: any;
  children?: Snippet;
  snippets?: Record<string, any>;
}

let __defaultNodes = (() => [])();
let __defaultTheme = (() => ({}))();

let {
  nodes = __defaultNodes,
  namespace = '',
  ariaLabel = null,
  theme = __defaultTheme,
  children: __childrenProp,
  snippets,
  ...__rozieAttrs
}: Props = $props();

const children = $derived(__childrenProp ?? snippets?.children);

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

// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';
// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).
let editor: any = null;

// $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
// tokens must be established during component init on Svelte (setContext is
// init-only — REQ-32) and Vue; providing inside $onMount would land setContext
// after init and fail. The value is a GETTER object so the identity is fixed at
// init while the live `editor` late-binds once $onMount assigns it — a plugin that
// mounts after the shell reads the current instance through the getter (the exact
// spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
// stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
// reads (spike 010 cross-file token contract); plugins read the live editor via
// `.instance`.
//
// The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
// top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
// tries to rewrite the ObjectMethod KEY `editor` into a member expression and
// crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
// that compile-path gap while the getter BODY `return editor` still late-binds to
// the live instance. (SCOPE FENCE: source workaround, no emitter edit.)

setContext('rozie-lexical-editor', {
  get instance() {
    return editor;
  }
});

onMount(() => {
  editor = lexical.createEditor({
    namespace: namespace || 'rozie-lexical',
    // The full v1.0 node CLASS set is declared here (Lexical requires all node
    // classes up front); consumer `nodes` are composed LAST so they win.
    nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...nodes],
    // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
    onError: (e: any) => {
      throw e;
    },
    theme: theme
  });

  // Bind the editor to the authored contenteditable host.
  editor.setRootElement(rootEl!);

  // Register the RichText baseline AND wire the per-target @mention decorator bridge
  // (D-06): mountDecorators returns an unregister fn, folded into the same
  // mergeRegister so the decorator listener tears down with everything else. Plugin
  // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
  // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
  const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));

  // Seed an empty paragraph so the caret has a block to land in when the document
  // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
  editor.update(() => {
    const root = lexical.$getRoot();
    if (root.getFirstChild() === null) {
      const paragraph = lexical.$createParagraphNode();
      root.append(paragraph);
    }
  });

  // Teardown colocated in the $onMount return (D-04): unregister everything, then
  // null the instance so a late teardown read sees a defined value.
  return () => {
    cleanup();
    editor = null;
  };
});
</script>

<div {...__rozieAttrs} class={["rozie-lexical", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-f679124a><div bind:this={rootEl} class="rozie-lexical-content" contenteditable={true} aria-label={ariaLabel} data-rozie-s-f679124a></div>{@render children?.()}</div>

<style>
:global {
  .rozie-lexical[data-rozie-s-f679124a] {
    display: block;
  }
  .rozie-lexical-content[data-rozie-s-f679124a] {
    min-height: 6rem;
    padding: 0.625rem 0.875rem;
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 6px;
    outline: none;
    font: inherit;
  }
  .rozie-lexical-content[data-rozie-s-f679124a]:focus {
    border-color: #4f46e5;
  }
}

:global {
  .rozie-lexical-content strong {
      font-weight: 700;
    }
  .rozie-lexical-content em {
      font-style: italic;
    }
  .rozie-lexical-content ul {
      margin: 0.25rem 0;
      padding-left: 1.5rem;
    }
  .rozie-lexical-content ol {
      margin: 0.25rem 0;
      padding-left: 1.5rem;
    }
  .rozie-lexical-content h1 {
      font-size: 1.5rem;
      margin: 0.5rem 0 0.375rem;
    }
  .rozie-lexical-content h2 {
      font-size: 1.25rem;
      margin: 0.5rem 0 0.375rem;
    }
  .rozie-lexical-content .rozie-mention {
      background: var(--rozie-lexical-mention-bg, #e0e7ff);
      border-radius: var(--rozie-lexical-mention-radius, 6px);
      padding: var(--rozie-lexical-mention-padding, 1px 6px);
      font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
    }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay, rozieToken } from '@rozie/runtime-angular';

// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';

// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).

interface DefaultCtx {}

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

    <div class="rozie-lexical" #rozieSpread_0 #rozieListenersTarget_1>
      
      <div #rootEl class="rozie-lexical-content" [contentEditable]="true" [attr.aria-label]="rozieAttr(ariaLabel())"></div>
      
      <ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" />
    </div>

  `,
  styles: [`
    :host(rozie-lexical-editor) { display: contents; }
    .rozie-lexical {
      display: block;
    }
    .rozie-lexical-content {
      min-height: 6rem;
      padding: 0.625rem 0.875rem;
      border: 1px solid rgba(0, 0, 0, 0.15);
      border-radius: 6px;
      outline: none;
      font: inherit;
    }
    .rozie-lexical-content:focus {
      border-color: #4f46e5;
    }

    ::ng-deep .rozie-lexical-content strong {
        font-weight: 700;
      }
    ::ng-deep .rozie-lexical-content em {
        font-style: italic;
      }
    ::ng-deep .rozie-lexical-content ul {
        margin: 0.25rem 0;
        padding-left: 1.5rem;
      }
    ::ng-deep .rozie-lexical-content ol {
        margin: 0.25rem 0;
        padding-left: 1.5rem;
      }
    ::ng-deep .rozie-lexical-content h1 {
        font-size: 1.5rem;
        margin: 0.5rem 0 0.375rem;
      }
    ::ng-deep .rozie-lexical-content h2 {
        font-size: 1.25rem;
        margin: 0.5rem 0 0.375rem;
      }
    ::ng-deep .rozie-lexical-content .rozie-mention {
        background: var(--rozie-lexical-mention-bg, #e0e7ff);
        border-radius: var(--rozie-lexical-mention-radius, 6px);
        padding: var(--rozie-lexical-mention-padding, 1px 6px);
        font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
      }
  `],
  providers: [
    {
      provide: rozieToken('rozie-lexical-editor'),
      useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => LexicalEditor)); return ({
  get instance() {
    return __rozieCtxHost.editor;
  }
}); },
    },
  ],
})
export class LexicalEditor {
  /**
   * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
   */
  nodes = input<any[]>((() => [])());
  /**
   * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
   */
  namespace = input<string>('');
  /**
   * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
   */
  ariaLabel = input<(string) | null>(null);
  /**
   * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
   */
  theme = input<Record<string, any>>((() => ({}))());
  rootEl = viewChild<ElementRef<HTMLDivElement>>('rootEl');
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });
  private __rozieDestroyRef = inject(DestroyRef);

  ngAfterViewInit() {
    this.editor = lexical.createEditor({
      namespace: this.namespace() || 'rozie-lexical',
      // The full v1.0 node CLASS set is declared here (Lexical requires all node
      // classes up front); consumer `nodes` are composed LAST so they win.
      nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...this.nodes()],
      // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
      onError: (e: any) => {
        throw e;
      },
      theme: this.theme()
    });

    // Bind the editor to the authored contenteditable host.
    // Bind the editor to the authored contenteditable host.
    this.editor.setRootElement(this.rootEl()!.nativeElement);

    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    const cleanup = mergeRegister(registerRichText(this.editor), mountDecorators(this.editor));

    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    this.editor.update(() => {
      const root = lexical.$getRoot();
      if (root.getFirstChild() === null) {
        const paragraph = lexical.$createParagraphNode();
        root.append(paragraph);
      }
    });

    // Teardown colocated in the $onMount return (D-04): unregister everything, then
    // null the instance so a late teardown read sees a defined value.
    this.__rozieDestroyRef.onDestroy(() => {
      cleanup();
      this.editor = null;
    });
  }

  editor: any = null;

  static ngTemplateContextGuard(
    _dir: LexicalEditor,
    _ctx: unknown,
  ): _ctx is DefaultCtx {
    return true;
  }

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

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

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

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

  private __rozieListenersRenderer = inject(Renderer2);

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

  private __rozieListenersDestroyRegistered_1 = false;

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

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

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

export default LexicalEditor;
tsx
import type { JSX } from 'solid-js';
import { mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieAttr, rozieContext } from '@rozie/runtime-solid';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';

// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).

__rozieInjectStyle('LexicalEditor-f679124a', `.rozie-lexical[data-rozie-s-f679124a] {
  display: block;
}
.rozie-lexical-content[data-rozie-s-f679124a] {
  min-height: 6rem;
  padding: 0.625rem 0.875rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 6px;
  outline: none;
  font: inherit;
}
.rozie-lexical-content[data-rozie-s-f679124a]:focus {
  border-color: #4f46e5;
}
.rozie-lexical-content strong {
    font-weight: 700;
  }
.rozie-lexical-content em {
    font-style: italic;
  }
.rozie-lexical-content ul {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content ol {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content h1 {
    font-size: 1.5rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content h2 {
    font-size: 1.25rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content .rozie-mention {
    background: var(--rozie-lexical-mention-bg, #e0e7ff);
    border-radius: var(--rozie-lexical-mention-radius, 6px);
    padding: var(--rozie-lexical-mention-padding, 1px 6px);
    font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
  }`);

interface LexicalEditorProps extends Omit<import('solid-js').ComponentProps<'div'>, 'nodes' | 'namespace' | 'ariaLabel' | 'theme' | 'children' | 'slots' | 'innerHTML' | 'innerText' | 'textContent' | 'ref'> {
  /**
   * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
   */
  nodes?: any[];
  /**
   * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
   */
  namespace?: string;
  /**
   * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
   */
  ariaLabel?: (string) | null;
  /**
   * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
   */
  theme?: Record<string, any>;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function LexicalEditor(_props: LexicalEditorProps): JSX.Element {
  const _merged = mergeProps({ nodes: (() => [])() as any[], namespace: '', ariaLabel: null, theme: (() => ({}))() as Record<string, any> }, _props);
  const [local, attrs] = splitProps(_merged, ['nodes', 'namespace', 'ariaLabel', 'theme', 'children']);
  const resolved = () => local.children;

  const __ctx_rozie_lexical_editor = rozieContext("rozie-lexical-editor");
  onMount(() => {
    editor = lexical.createEditor({
      namespace: local.namespace || 'rozie-lexical',
      // The full v1.0 node CLASS set is declared here (Lexical requires all node
      // classes up front); consumer `nodes` are composed LAST so they win.
      nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...local.nodes],
      // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
      onError: (e: any) => {
        throw e;
      },
      theme: local.theme
    });

    // Bind the editor to the authored contenteditable host.
    editor.setRootElement(rootElRef!);

    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    const cleanup = mergeRegister(registerRichText(editor), mountDecorators(editor));

    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    editor.update(() => {
      const root = lexical.$getRoot();
      if (root.getFirstChild() === null) {
        const paragraph = lexical.$createParagraphNode();
        root.append(paragraph);
      }
    });

    // Teardown colocated in the $onMount return (D-04): unregister everything, then
    // null the instance so a late teardown read sees a defined value.
    onCleanup(() => {
      cleanup();
      editor = null;
    });
  });
  let rootElRef: HTMLElement | null = null;

  // The live editor instance — null before mount / after teardown. Declared at
  // TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
  // $provide getter below AND the Solid-split onCleanup teardown, which the Solid
  // emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
  // gotcha — a mount-local `let` would be TS2304 in the teardown).
  let editor: any = null;

  // $provide the editor at INIT (top-level setup), NOT inside $onMount. Context
  // tokens must be established during component init on Svelte (setContext is
  // init-only — REQ-32) and Vue; providing inside $onMount would land setContext
  // after init and fail. The value is a GETTER object so the identity is fixed at
  // init while the live `editor` late-binds once $onMount assigns it — a plugin that
  // mounts after the shell reads the current instance through the getter (the exact
  // spike 010 `{ get color() {…} }` late-binding pattern). The token string is the
  // stable cross-file identity the plugin/toolbar `$inject('rozie-lexical-editor')`
  // reads (spike 010 cross-file token contract); plugins read the live editor via
  // `.instance`.
  //
  // The getter key is `instance`, NOT `editor`: naming it `editor` collides with the
  // top-level `let editor`, and the emitter's reactive-identifier rewrite pass then
  // tries to rewrite the ObjectMethod KEY `editor` into a member expression and
  // crashes (@babel/types ObjectMethod-key invariant). Renaming the key sidesteps
  // that compile-path gap while the getter BODY `return editor` still late-binds to
  // the live instance. (SCOPE FENCE: source workaround, no emitter edit.)

  return (
    <__ctx_rozie_lexical_editor.Provider value={{
  get instance() {
    return editor;
  }
}}>
    <>
    <div {...attrs} class={"rozie-lexical" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-f679124a="">
      
      <div ref={(el) => { rootElRef = el as HTMLElement; }} class={"rozie-lexical-content"} contentEditable={true} aria-label={rozieAttr(local.ariaLabel)} data-rozie-s-f679124a="" />
      
      {resolved()}
    </div>
    </>
    </__ctx_rozie_lexical_editor.Provider>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { injectGlobalStyles, rozieAttr, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
// D-05 / REQ-37: the namespace-import form is the ONLY cross-target-safe way to
// use Lexical's `$`-prefixed API. Every `$`-call below is `lexical.$…` (a property
// access), never a bare `$`-identifier — that is what keeps the emitted Svelte
// clean of `dollar_prefix_invalid`.
import * as lexical from 'lexical';
// Non-`$` helpers use ordinary named imports (unaffected by the Svelte reservation).
import { registerRichText, HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { mergeRegister } from '@lexical/utils';
// The reference @mention DecoratorNode (D-07) + the per-target mount bridge
// (D-06/REQ-39). BOTH are VENDORED by codegen into every leaf: `./MentionNode`
// resolves to the shared neutral node, and `./mountDecorators` resolves to the
// leaf's target-matched hand-written bridge (one import specifier, 5 different
// vendored files). Only the non-`$` `MentionNode` CLASS + `mountDecorators` fn are
// imported here — never a `$`-prefixed named import (that would trip Svelte's
// dollar_prefix_invalid, D-05).
import { MentionNode } from './MentionNode';
import { mountDecorators } from './mountDecorators';

// The live editor instance — null before mount / after teardown. Declared at
// TOP-LEVEL script scope (NOT inside $onMount) so it is reachable from BOTH the
// $provide getter below AND the Solid-split onCleanup teardown, which the Solid
// emitter hoists OUTSIDE the mount-body IIFE (the ADDING-A-FAMILY cross-phase-scope
// gotcha — a mount-local `let` would be TS2304 in the teardown).

const __rozieCtx_rozie_lexical_editor = createContext(Symbol.for("rozie:rozie-lexical-editor"));

@customElement('rozie-lexical-editor')
export default class LexicalEditor extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.rozie-lexical[data-rozie-s-f679124a] {
  display: block;
}
.rozie-lexical-content[data-rozie-s-f679124a] {
  min-height: 6rem;
  padding: 0.625rem 0.875rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 6px;
  outline: none;
  font: inherit;
}
.rozie-lexical-content[data-rozie-s-f679124a]:focus {
  border-color: #4f46e5;
}
.rozie-lexical-content strong {
    font-weight: 700;
  }
.rozie-lexical-content em {
    font-style: italic;
  }
.rozie-lexical-content ul {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content ol {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content h1 {
    font-size: 1.5rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content h2 {
    font-size: 1.25rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content .rozie-mention {
    background: var(--rozie-lexical-mention-bg, #e0e7ff);
    border-radius: var(--rozie-lexical-mention-radius, 6px);
    padding: var(--rozie-lexical-mention-padding, 1px 6px);
    font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
  }
`;

  /**
   * Extra Lexical node classes to register at editor creation. Lexical requires every node class to be declared up front, so consumer node extensions are passed here and composed after the built-in RichText/List/Link + `@mention` `MentionNode` set (the reference DecoratorNode is registered by the shell itself; these consumer nodes are composed last so they win).
   */
  @property({ type: Array }) nodes: any[] = [];
  /**
   * The Lexical editor `namespace` (scopes clipboard/collaboration). Falls back to `rozie-lexical` when left empty.
   */
  @property({ type: String, reflect: true }) namespace: string = '';
  /**
   * Accessible name (`aria-label`) applied to the contenteditable host. Omitted from the DOM when unset — supply one for a labelled editing region.
   */
  @property({ type: String, reflect: true, attribute: 'aria-label' }) ariaLabel: string | null = null;
  /**
   * Lexical `theme` object mapping node/format types to CSS class names. The styling hook for this deliberately-unstyled primitive (D-12) — bring your own design-system classes.
   */
  @property({ type: Object }) theme: any = {};
  @query('[data-rozie-ref="rootEl"]') private _refRootEl!: HTMLElement;
private __rozieCtxProvider_rozie_lexical_editor = new ContextProvider(this, { context: __rozieCtx_rozie_lexical_editor, initialValue: ((__rozieCtxHost) => ({
  get instance() {
    return __rozieCtxHost.editor;
  }
}))(this) });

  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];

  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:not([name])');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDefault = this._slotDefaultElements.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._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

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

    this._disconnectCleanups.push((() => {
      cleanup();
      this.editor = null;
    }));

    this.editor = lexical.createEditor({
      namespace: this.namespace || 'rozie-lexical',
      // The full v1.0 node CLASS set is declared here (Lexical requires all node
      // classes up front); consumer `nodes` are composed LAST so they win.
      nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, MentionNode, ...this.nodes],
      // Fail-loud: rethrow rather than swallow editor-state corruption (T-76-01).
      onError: (e: any) => {
        throw e;
      },
      theme: this.theme
    });

    // Bind the editor to the authored contenteditable host.
    // Bind the editor to the authored contenteditable host.
    this.editor.setRootElement(this._refRootEl);

    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    // Register the RichText baseline AND wire the per-target @mention decorator bridge
    // (D-06): mountDecorators returns an unregister fn, folded into the same
    // mergeRegister so the decorator listener tears down with everything else. Plugin
    // components (wave 2) add History/List/Link BEHAVIOR against the same $injected
    // editor. mountDecorators runs AFTER setRootElement so getElementByKey resolves.
    const cleanup = mergeRegister(registerRichText(this.editor), mountDecorators(this.editor));

    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    // Seed an empty paragraph so the caret has a block to land in when the document
    // is empty (the spike 015 seed pattern, in the `lexical.$…` namespace form).
    this.editor.update(() => {
      const root = lexical.$getRoot();
      if (root.getFirstChild() === null) {
        const paragraph = lexical.$createParagraphNode();
        root.append(paragraph);
      }
    });

    // Teardown colocated in the $onMount return (D-04): unregister everything, then
    // null the instance so a late teardown read sees a defined value.
  }

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

  render() {
    return html`
<div class="rozie-lexical" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-f679124a>
  
  <div class="rozie-lexical-content" contenteditable=${true} aria-label=${rozieAttr(this.ariaLabel)} data-rozie-ref="rootEl" data-rozie-s-f679124a></div>
  
  <slot></slot>
</div>
`;
  }

  editor: any = null;

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

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

injectGlobalStyles('rozie-lexical-editor-0bf26d9c-global', `
.rozie-lexical-content strong {
    font-weight: 700;
  }
.rozie-lexical-content em {
    font-style: italic;
  }
.rozie-lexical-content ul {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content ol {
    margin: 0.25rem 0;
    padding-left: 1.5rem;
  }
.rozie-lexical-content h1 {
    font-size: 1.5rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content h2 {
    font-size: 1.25rem;
    margin: 0.5rem 0 0.375rem;
  }
.rozie-lexical-content .rozie-mention {
    background: var(--rozie-lexical-mention-bg, #e0e7ff);
    border-radius: var(--rozie-lexical-mention-radius, 6px);
    padding: var(--rozie-lexical-mention-padding, 1px 6px);
    font-size: var(--rozie-lexical-mention-font-size, 0.875rem);
  }
`);

Each is a real component for its framework — React hooks, Vue <script setup>, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same $provide/$inject editor-sharing contract, same plugins, identical on every target.

See also ​

Pre-1.0 — APIs may change between minor versions.