DOM Runtime

The browser adapter that drives the headless engine from real input and paints its state back out.

@spine-editor/dom is the browser adapter for @spine-editor/core. The engine is headless — it has no concept of a keystroke, a paste event, or a caret rectangle. Something has to turn real browser input into Intents, feed them through the engine, and paint the committed state back out. That is this package.

The central rule is one-way: the DOM is a projection, never the source of semantic truth. The runtime reads events, cancels their native behavior when the engine will handle the edit, dispatches an Intent, and waits for the engine to commit. On commit, the renderer rebuilds the DOM from the new state; the selection overlay measures the committed selection and repositions. Browser evidence can produce intents, but the DOM never mutates semantic state directly.

That rule is what lets the DOM layout churn on every commit without losing state. If the renderer swaps a <span> for a <div> mid-session, the engine does not notice — selection round-trips through data attributes, not DOM identity.

Entry point

createSpineDomRuntime(config: SpineDomRuntimeConfig): SpineDomRuntime

Defined in packages/dom/src/runtime/runtime.ts. A SpineDomRuntime exposes lifecycle (mount, destroy), imperative helpers (focus, selection-aware queries like getSelectedImageMedia), and the rootElement the host embeds.

Layers

DOM runtime data flowSelect a layer to inspect its boundary

Runtime projections after commit

After commit, DOM work splits into projections. These layers render, map, or paint from committed state; none can author semantic truth.

Selected layer · Semantic pipeline

Engine

The headless core compiles the intent into an inspectable plan, lowers it to a transaction, applies deterministic steps, normalizes, and commits the next EditorState.

Only the engine owns semantic truth and history.

ReceivesIntent
EmitsCommitted EditorState

Each layer has one job, and the layers only talk upward through typed intents and downward through the committed EditorState. Select a layer above to inspect what it receives, what it emits, and which boundary it owns.

  1. Substratepackages/dom/src/substrate. Owns every addEventListener in the runtime: keydown, beforeinput, pointer, clipboard, compositionstart/end, focus, drag-and-drop. It translates each event into an Intent and dispatches. It never mutates document state directly.
  2. Selection mappingpackages/dom/src/selection-mapping. Turns a browser Range into an engine Selection (on input) and a Selection back into a DOM Range (for the overlay). Reads the internal data attributes documented in Data attributes.
  3. Rendererpackages/dom/src/renderer. Full re-render of the document subtree on every commit. Emits both public styling hooks and internal mapping anchors. The renderer favors recomputation over drift.
  4. Selection overlaypackages/dom/src/selection-overlay. A separate absolutely-positioned layer that draws the caret and per-line highlight rects. The document subtree sets user-select: none; the visible caret is a positioned <div> with transform: translate3d(...) and a CSS blink animation, so caret movement does not touch the document tree.

Data flow

Browser event → Substrate → Intent → Engine → Commit → Renderer + Overlay update

Every semantic edit follows this path. There are no mutation back channels: the selection overlay does not talk to the substrate, the renderer does not peek at browser selection, and the substrate does not rewrite the document. Each layer can be replaced independently as long as it honors its interface.

Why a full re-render on every commit

Incremental renderers are faster in the average case and wrong in the worst case: an edit in a nested list deep in the tree has to compute exactly which list items shift, which text leaves merge, which marks coalesce. Getting that wrong leaves the DOM out of sync with the engine, which is the class of bug the architecture is designed to rule out.

The renderer trades throughput for correctness. Every commit replaces the runtime content element with a freshly-rendered fragment. Structural sharing in the persistent document means unchanged subtrees are stable inputs, so the per-commit cost is predictable and bounded by document size. That tradeoff is suitable for prose-sized documents.

The selection overlay and caret live in a separate layer, so typing and arrow-key movement do not reflow the document tree. The blinking caret is a GPU-composited transform; it does not invalidate layout.