Editor Behavior Matrix

The matrix exists to catch drift. Every primitive in the editor has a small set of testable claims. When the code changes and a claim becomes false, this page is the place that should also change. Failing to update it is the signal that the claim is now silent and undefended.

Each row carries four columns:

  • Guarantees. What the primitive promises. A consumer can rely on this.
  • Does not guarantee. What the primitive deliberately does not promise. A consumer who needs this builds it elsewhere.
  • Known edge cases. Where the contract bends. Documented so consumers aren’t surprised.
  • Test coverage. Where the guarantee is asserted, or untested if no test exists.

When a row is untested, the guarantee lives only in code comments and reader memory. That is the most vulnerable form a contract can take.

Canvas Layer

block-canvas

AspectDetail
GuaranteesTracks selection, focus, and keyboard routing for [data-block-id] elements. Click and arrow keys change focus. Editable elements (contenteditable, input, textarea) pass events through to the host.
Does not guaranteeCRUD on blocks. Multi-block selection. Drag-drop integration (use drag-drop and canvas-drop-zone).
Known edge casesSlash command position is hardcoded to a 144px offset from container center. Custom rendering surfaces with non-standard dimensions will see the slash menu in the wrong place.
Test coverageuntested

block-handler

AspectDetail
GuaranteesSubscribes to block-canvas, clipboard, and history. Exposes a nanostores atom of unified state. Undo and redo move backward and forward through history entries.
Does not guaranteeBlock CRUD (create, delete, reorder). Use block-operations for pure mutations and apply them through the consumer.
Known edge casesCut-and-paste round-trips through the clipboard primitive’s MIME type. Copying from outside the editor uses text-only fallback.
Test coverageuntested (composition primitive; covered indirectly through document-editor)

block-wrapper

AspectDetail
GuaranteesPer-block hover chrome state (drag handle, action menu) computed as isHovered || isFocused || menuOpen. Cleanup on destroy.
Does not guaranteeRender of the chrome elements. Only the state. The consumer renders.
Known edge casesMenu state is consumer-managed. block-wrapper listens; it does not store.
Test coverageuntested

document-editor

AspectDetail
GuaranteesDOM reconciliation on every input event. Markdown shortcuts: # , ## , > , - , 1. , ``` for h1–h6, blockquote, list, code. Cmd+Alt+0–4 converts the focused block’s type. Selection-delete is browser-handled; everything else routes through block-operations.
Does not guaranteeInline mark persistence on user edit (see Known Gaps). Custom block types beyond the built-ins.
Known edge casesShortcut detection runs on beforeinput, not keydown. IMEs with intermediate composition states may produce surprising shortcut activations.
Test coverageuntested

block-operations

AspectDetail
GuaranteesPure functions. splitBlock, mergeWithPrevious, mergeWithNext, deleteBlock, convertBlockType, insertBlocksAt return new arrays without mutating inputs. New blocks get fresh UUIDs. blockContentToText flattens InlineContent[] to a string.
Does not guaranteeSide effects. Persistence. Selection state changes.
Known edge casesMerge across heterogeneous block types coerces to the destination type. A list-item merged into a text block becomes part of the text block’s content.
Test coveragetested at packages/ui/src/primitives/block-operations.test.ts

Input Layer

keyboard-handler

AspectDetail
GuaranteesType-safe keyboard routing with modifier support. Space is normalized to ' ' in the key map. Capture-phase support via options. Cleanup on unsubscribe.
Does not guaranteeCross-IME consistency. Browser-specific keycode quirks.
Known edge casesModifier state is read from the event, not from a separate state machine. Compound modifier sequences (Cmd then Shift then K) need consumer-side state.
Test coverageuntested

escape-keydown

AspectDetail
GuaranteesDocument-level Escape listener. Cleanup on unsubscribe.
Does not guaranteeStack-aware dismissal. Use dismissable-layer for nested overlays.
Known edge casesEscape during IME composition is intercepted by the browser before reaching the listener.
Test coverageuntested

focus-trap

AspectDetail
GuaranteesTab and Shift+Tab cycle within the trap element. Restores previously focused element on cleanup. iOS Safari elastic-scroll prevention available via preventBodyScroll.
Does not guaranteeCross-window focus. Iframe focus.
Known edge casesDisabled tabbables are skipped via the selector. Custom interactive elements without tabindex are not detected.
Test coverageuntested

roving-focus

AspectDetail
GuaranteesExactly one item has tabindex=0 at any time. RTL inversion via Direction. Disabled items skipped.
Does not guaranteeVisual focus indicators. Consumer styles the focus ring.
Known edge casesAdding or removing items mid-session requires refreshRovingFocus. The primitive does not observe DOM mutations.
Test coverageuntested

cursor-tracker

AspectDetail
GuaranteesfindBlockElement(node) walks up the DOM until it finds the nearest [data-block-id] ancestor. Returns null if no ancestor matches.
Does not guaranteeSelection state across multiple editor instances. Cross-iframe selection.
Known edge casesA block element rendered as a React fragment has no DOM node and breaks the walk. The render-path contract requires single-node block elements.
Test coverageuntested

Formatting Layer

inline-formatter

AspectDetail
GuaranteesBidirectional bridge: serializeSelection() -> InlineContent[] walks the DOM, deserializeToDOM(content) -> DocumentFragment walks the array. Format presets ship for bold, italic, code, strikethrough, link. SSR-safe (no-op if typeof window === 'undefined').
Does not guaranteePersistence of user-applied formatting back into block content. Editor.tsx never calls serializeSelection (see Known Gaps).
Known edge casesPartial format removal across mixed-mark ranges splits and rebuilds the affected nodes. Performance is O(n) on the selected range length.
Test coverageuntested

inline-toolbar

AspectDetail
GuaranteesViewport-aware position. Prefers above the selection. Flips below on collision. 8px viewport padding. getModifierKey() returns Cmd on Mac, Ctrl elsewhere.
Does not guaranteeRender of toolbar buttons. Provides config and positioning.
Known edge casesSelection that crosses block boundaries: position is computed for the first range, not the union.
Test coverageuntested

command-palette

AspectDetail
GuaranteesSlash trigger fires at start-of-line or after whitespace. Fuzzy scoring: consecutive +2, start-of-word +3, base +1 per char.
Does not guaranteeAsync command actions. Commands run synchronously; consumers wrap async work themselves.
Known edge casesTrigger inside an inline-code mark is suppressed via the formatter’s mark-aware check.
Test coverageuntested

Palette Layer

block-palette

AspectDetail
GuaranteesCategorized grid of draggable templates. Live region for screen reader announcements. Drag MIME application/x-rafters-block and application/x-rafters-drag-data. Event delegation through [data-palette-item][data-palette-id].
Does not guaranteeItem rendering. Consumer provides renderPaletteItem.
Known edge casesInteractive elements (Button, Input) inside a preview wrapper eat clicks. Wrapper requires pointer-events-none.
Test coverageuntested

rule-palette

AspectDetail
GuaranteesSame shape as block-palette for rules. Drag MIME application/x-rafters-rule.
Does not guaranteeRule application logic. Drop handler invokes consumer callback.
Known edge casesThe rule palette does not currently expose a remove-rule UI. Add-only.
Test coverageuntested

typeahead

AspectDetail
GuaranteesType-to-search with prefix and fuzzy modes. fuzzyScore(query, target) returns a numeric score; higher is better. Global type-to-search timeout 1000ms.
Does not guaranteeDiacritic-insensitive matching. Locale-aware sort.
Known edge casesAn empty query returns 0 for every target. Consumers should handle the empty-query case explicitly.
Test coverageuntested

Drag, Drop, State Layer

drag-drop

AspectDetail
GuaranteesMouse, keyboard, and touch drag. Touch long-press 300ms. Screen reader announcements built in. MIME application/x-rafters-drag-data.
Does not guaranteeCross-window drag. Desktop file-drop integration.
Known edge casesModule-level shared keyboard drag state across all instances. resetDragDropState() is the escape hatch when a stuck state needs clearing.
Test coverageuntested

canvas-drop-zone

AspectDetail
GuaranteesY-axis midpoint determines insertion index. rAF-throttled dragover for performance.
Does not guaranteeHorizontal layouts. The midpoint algorithm assumes vertical block flow.
Known edge casesEmpty canvas accepts drops at index 0. Single-block canvas accepts before or after based on midpoint.
Test coverageuntested

clipboard

AspectDetail
GuaranteesCopy and paste with custom MIME and text fallback. Permission denial handled gracefully.
Does not guaranteeCross-origin clipboard reads when permission is denied.
Known edge casesBrowser permission prompts can interrupt the flow. The primitive returns null on denial; the consumer decides what to do.
Test coverageuntested

history

AspectDetail
Guaranteespush, undo, redo, batch, clear, canUndo, canRedo. Batch mode accumulates without recording until close. Limit enforces FIFO drop of oldest entries.
Does not guaranteeCross-tab undo. Persistence to storage.
Known edge casesDeep-equal duplicate detection on push. Pushing identical state twice is a no-op.
Test coverageuntested

Overlay Layer

block-context-menu

AspectDetail
GuaranteesShift+F10 opens the menu for the focused block. Menu position respects 4px gap from viewport edges. Escape and outside-click dismiss.
Does not guaranteeRight-click suppression on touch. Touch-equivalent interaction is a long-press handled by drag-drop.
Known edge casesMenu items that themselves open submenus are out of scope. The primitive is single-level.
Test coverageuntested

dismissable-layer

AspectDetail
GuaranteesOutside-click, Escape, focus-outside detection unified. Stacking support for nested layers via createDismissableLayerStack. CSS injection disables body pointer-events when modal.
Does not guaranteeAnimation. Dismissal is immediate.
Known edge casesNested layers must be created via the stack helper, not createDismissableLayer directly.
Test coverageuntested

collision-detector

AspectDetail
GuaranteescomputePosition returns coordinates and chosen side after collision detection. applyPosition writes them to the floating element’s style. autoPosition reapplies on resize via ResizeObserver.
Does not guaranteeScroll-aware repositioning by default. Consumers wire scroll listeners themselves.
Known edge casesAnchor inside a transformed ancestor: position is computed in viewport coordinates and may need adjustment.
Test coverageuntested

outside-click

AspectDetail
GuaranteesDetects clicks and pointer-down outside the target. Touch deduplication via 50ms threshold. Capture phase.
Does not guaranteeCross-iframe detection.
Known edge casesA click inside an open shadow root may be reported as outside if the host element is outside.
Test coverageuntested

hover-delay

AspectDetail
GuaranteesConfigurable show and hide delays. Global open timestamp coordinates skip-delay across instances. 300ms threshold for “recently open.”
Does not guaranteeHover intent detection by velocity. Use createHoverIntent for direction-aware logic.
Known edge casesLong delays interact with scroll: a tooltip scheduled to open may fire after the trigger has scrolled out of view.
Test coverageuntested

dialog-aria

AspectDetail
GuaranteesPure functions returning ARIA prop bags. Includes data-state for visual state.
Does not guaranteeFocus management. focus-trap is a separate concern.
Known edge casesThe functions return new objects on every call. Memoize in the consumer if reference equality matters.
Test coverageuntested

aria-manager

AspectDetail
GuaranteesType-safe ARIA attribute mutation with spec validation. Auto-generates IDs for relationships when missing.
Does not guaranteeCross-element relationship cleanup. Removing one side of a relationship leaves the other side referencing a now-missing id.
Known edge casesSetting an invalid attribute value throws. Consumers must catch or pre-validate.
Test coverageuntested

Serialization Layer

EditorSerializer interface

AspectDetail
Guaranteesid: string, extensions: readonly string[], deserialize(input): DeserializeResult, serialize(blocks, frontmatter?): string.
Does not guaranteeRound-trip equivalence. Adapter implementations claim that themselves.
Known edge casesExtensions are readonly arrays; consumers must not mutate.
Test coveragetested via concrete adapter tests

mdxSerializer

AspectDetail
GuaranteesRound-trip preserves bold, italic, code, strikethrough, link marks. Frontmatter parses YAML keys (booleans, numbers, arrays) and serializes back. MDX JSX components stored as type: 'component' with meta.props.
Does not guaranteeMDX expressions with side effects. Custom remark plugins.
Known edge casesJSX attributes parsed by jsxAttributesToProps flatten complex expressions to strings. Consumer reconstruction is consumer’s problem.
Test coveragetested at packages/ui/src/primitives/serializer-mdx.test.ts and packages/ui/src/primitives/serializer-mdx-fixtures.test.ts

Composites toMdx (legacy)

AspectDetail
GuaranteesRenders heading, text, blockquote, list, divider, grid, composite:* to markdown.
Does not guaranteeInline mark preservation. Frontmatter. Round-trip parsing. quote, input, button, image (drops to comment).
Known edge casesMAX_DEPTH=50 differs from instantiateBlocks maxDepth=10.
Test coveragetested at packages/composites/src/serializer.test.ts

Composites Layer

Composite manifest

AspectDetail
GuaranteesZod-validated. id matches /^[a-z0-9-]+$/. cognitiveLoad is integer 1–10. category is free-form string.
Does not guaranteeBackward-compat for old composites missing solves, appliesWhen, usagePatterns. They parse fine; consumers must handle the optional fields.
Known edge casesDisplay name with non-kebab characters fails serializeToComposite validation. Names should yield valid kebab ids.
Test coveragetested at packages/composites/src/manifest.test.ts

bridge

AspectDetail
GuaranteesinstantiateBlocks regenerates every block id with crypto.randomUUID(). parentId and children references are remapped. Composite expansion supports maxDepth=10. serializeToComposite derives input from root rules, output from leaf rules, keywords from block types and rule names.
Does not guaranteeDeterminism across runs. Each call produces fresh UUIDs.
Known edge casesNested composite without explicit parentId inherits the parent’s new id. Missing composite resolves to a fallback text block “Unknown composite: {id}”.
Test coveragetested at packages/composites/src/bridge.test.ts

registry

AspectDetail
GuaranteesO(1) get, in-memory Map keyed by manifest.id. search returns fuzzy-scored matches sorted descending.
Does not guaranteePersistence. Cross-process visibility.
Known edge casesImports fuzzyScore from @rafters/ui (architectural violation, see Known Gaps).
Test coveragetested at packages/composites/src/registry.test.ts

rules

AspectDetail
GuaranteesmatchRules returns { matched, missing, extra, compatible }. Compatibility is nominal name matching. Filter helpers findCompatibleConsumers and findCompatibleProducers.
Does not guaranteeRuntime data validation. Zod schemas in built-in-rules/ exist but are not invoked from blocks (see Known Gaps).
Known edge casesRule name collisions across unrelated codebases register as compatible.
Test coveragetested at packages/composites/src/rules.test.ts and packages/composites/src/built-in-rules.test.ts

Reading The Matrix

A tested entry is a contract that gets exercised by the test suite. A claim that breaks should produce a failing test.

An untested entry is a contract that lives in this page and in the source. A claim that breaks will be silent until a consumer notices.

The honest path forward is to convert untested rows into tests. Each guarantee should map to at least one assertion. The matrix is the spec; the tests are the enforcement.