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
| Aspect | Detail |
|---|
| Guarantees | Tracks 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 guarantee | CRUD on blocks. Multi-block selection. Drag-drop integration (use drag-drop and canvas-drop-zone). |
| Known edge cases | Slash 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 coverage | untested |
block-handler
| Aspect | Detail |
|---|
| Guarantees | Subscribes 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 guarantee | Block CRUD (create, delete, reorder). Use block-operations for pure mutations and apply them through the consumer. |
| Known edge cases | Cut-and-paste round-trips through the clipboard primitive’s MIME type. Copying from outside the editor uses text-only fallback. |
| Test coverage | untested (composition primitive; covered indirectly through document-editor) |
block-wrapper
| Aspect | Detail |
|---|
| Guarantees | Per-block hover chrome state (drag handle, action menu) computed as isHovered || isFocused || menuOpen. Cleanup on destroy. |
| Does not guarantee | Render of the chrome elements. Only the state. The consumer renders. |
| Known edge cases | Menu state is consumer-managed. block-wrapper listens; it does not store. |
| Test coverage | untested |
document-editor
| Aspect | Detail |
|---|
| Guarantees | DOM 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 guarantee | Inline mark persistence on user edit (see Known Gaps). Custom block types beyond the built-ins. |
| Known edge cases | Shortcut detection runs on beforeinput, not keydown. IMEs with intermediate composition states may produce surprising shortcut activations. |
| Test coverage | untested |
block-operations
| Aspect | Detail |
|---|
| Guarantees | Pure 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 guarantee | Side effects. Persistence. Selection state changes. |
| Known edge cases | Merge 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 coverage | tested at packages/ui/src/primitives/block-operations.test.ts |
keyboard-handler
| Aspect | Detail |
|---|
| Guarantees | Type-safe keyboard routing with modifier support. Space is normalized to ' ' in the key map. Capture-phase support via options. Cleanup on unsubscribe. |
| Does not guarantee | Cross-IME consistency. Browser-specific keycode quirks. |
| Known edge cases | Modifier 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 coverage | untested |
escape-keydown
| Aspect | Detail |
|---|
| Guarantees | Document-level Escape listener. Cleanup on unsubscribe. |
| Does not guarantee | Stack-aware dismissal. Use dismissable-layer for nested overlays. |
| Known edge cases | Escape during IME composition is intercepted by the browser before reaching the listener. |
| Test coverage | untested |
focus-trap
| Aspect | Detail |
|---|
| Guarantees | Tab and Shift+Tab cycle within the trap element. Restores previously focused element on cleanup. iOS Safari elastic-scroll prevention available via preventBodyScroll. |
| Does not guarantee | Cross-window focus. Iframe focus. |
| Known edge cases | Disabled tabbables are skipped via the selector. Custom interactive elements without tabindex are not detected. |
| Test coverage | untested |
roving-focus
| Aspect | Detail |
|---|
| Guarantees | Exactly one item has tabindex=0 at any time. RTL inversion via Direction. Disabled items skipped. |
| Does not guarantee | Visual focus indicators. Consumer styles the focus ring. |
| Known edge cases | Adding or removing items mid-session requires refreshRovingFocus. The primitive does not observe DOM mutations. |
| Test coverage | untested |
cursor-tracker
| Aspect | Detail |
|---|
| Guarantees | findBlockElement(node) walks up the DOM until it finds the nearest [data-block-id] ancestor. Returns null if no ancestor matches. |
| Does not guarantee | Selection state across multiple editor instances. Cross-iframe selection. |
| Known edge cases | A 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 coverage | untested |
| Aspect | Detail |
|---|
| Guarantees | Bidirectional 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 guarantee | Persistence of user-applied formatting back into block content. Editor.tsx never calls serializeSelection (see Known Gaps). |
| Known edge cases | Partial format removal across mixed-mark ranges splits and rebuilds the affected nodes. Performance is O(n) on the selected range length. |
| Test coverage | untested |
| Aspect | Detail |
|---|
| Guarantees | Viewport-aware position. Prefers above the selection. Flips below on collision. 8px viewport padding. getModifierKey() returns Cmd on Mac, Ctrl elsewhere. |
| Does not guarantee | Render of toolbar buttons. Provides config and positioning. |
| Known edge cases | Selection that crosses block boundaries: position is computed for the first range, not the union. |
| Test coverage | untested |
command-palette
| Aspect | Detail |
|---|
| Guarantees | Slash trigger fires at start-of-line or after whitespace. Fuzzy scoring: consecutive +2, start-of-word +3, base +1 per char. |
| Does not guarantee | Async command actions. Commands run synchronously; consumers wrap async work themselves. |
| Known edge cases | Trigger inside an inline-code mark is suppressed via the formatter’s mark-aware check. |
| Test coverage | untested |
Palette Layer
block-palette
| Aspect | Detail |
|---|
| Guarantees | Categorized 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 guarantee | Item rendering. Consumer provides renderPaletteItem. |
| Known edge cases | Interactive elements (Button, Input) inside a preview wrapper eat clicks. Wrapper requires pointer-events-none. |
| Test coverage | untested |
rule-palette
| Aspect | Detail |
|---|
| Guarantees | Same shape as block-palette for rules. Drag MIME application/x-rafters-rule. |
| Does not guarantee | Rule application logic. Drop handler invokes consumer callback. |
| Known edge cases | The rule palette does not currently expose a remove-rule UI. Add-only. |
| Test coverage | untested |
typeahead
| Aspect | Detail |
|---|
| Guarantees | Type-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 guarantee | Diacritic-insensitive matching. Locale-aware sort. |
| Known edge cases | An empty query returns 0 for every target. Consumers should handle the empty-query case explicitly. |
| Test coverage | untested |
Drag, Drop, State Layer
drag-drop
| Aspect | Detail |
|---|
| Guarantees | Mouse, keyboard, and touch drag. Touch long-press 300ms. Screen reader announcements built in. MIME application/x-rafters-drag-data. |
| Does not guarantee | Cross-window drag. Desktop file-drop integration. |
| Known edge cases | Module-level shared keyboard drag state across all instances. resetDragDropState() is the escape hatch when a stuck state needs clearing. |
| Test coverage | untested |
canvas-drop-zone
| Aspect | Detail |
|---|
| Guarantees | Y-axis midpoint determines insertion index. rAF-throttled dragover for performance. |
| Does not guarantee | Horizontal layouts. The midpoint algorithm assumes vertical block flow. |
| Known edge cases | Empty canvas accepts drops at index 0. Single-block canvas accepts before or after based on midpoint. |
| Test coverage | untested |
clipboard
| Aspect | Detail |
|---|
| Guarantees | Copy and paste with custom MIME and text fallback. Permission denial handled gracefully. |
| Does not guarantee | Cross-origin clipboard reads when permission is denied. |
| Known edge cases | Browser permission prompts can interrupt the flow. The primitive returns null on denial; the consumer decides what to do. |
| Test coverage | untested |
history
| Aspect | Detail |
|---|
| Guarantees | push, undo, redo, batch, clear, canUndo, canRedo. Batch mode accumulates without recording until close. Limit enforces FIFO drop of oldest entries. |
| Does not guarantee | Cross-tab undo. Persistence to storage. |
| Known edge cases | Deep-equal duplicate detection on push. Pushing identical state twice is a no-op. |
| Test coverage | untested |
Overlay Layer
| Aspect | Detail |
|---|
| Guarantees | Shift+F10 opens the menu for the focused block. Menu position respects 4px gap from viewport edges. Escape and outside-click dismiss. |
| Does not guarantee | Right-click suppression on touch. Touch-equivalent interaction is a long-press handled by drag-drop. |
| Known edge cases | Menu items that themselves open submenus are out of scope. The primitive is single-level. |
| Test coverage | untested |
dismissable-layer
| Aspect | Detail |
|---|
| Guarantees | Outside-click, Escape, focus-outside detection unified. Stacking support for nested layers via createDismissableLayerStack. CSS injection disables body pointer-events when modal. |
| Does not guarantee | Animation. Dismissal is immediate. |
| Known edge cases | Nested layers must be created via the stack helper, not createDismissableLayer directly. |
| Test coverage | untested |
collision-detector
| Aspect | Detail |
|---|
| Guarantees | computePosition 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 guarantee | Scroll-aware repositioning by default. Consumers wire scroll listeners themselves. |
| Known edge cases | Anchor inside a transformed ancestor: position is computed in viewport coordinates and may need adjustment. |
| Test coverage | untested |
outside-click
| Aspect | Detail |
|---|
| Guarantees | Detects clicks and pointer-down outside the target. Touch deduplication via 50ms threshold. Capture phase. |
| Does not guarantee | Cross-iframe detection. |
| Known edge cases | A click inside an open shadow root may be reported as outside if the host element is outside. |
| Test coverage | untested |
hover-delay
| Aspect | Detail |
|---|
| Guarantees | Configurable show and hide delays. Global open timestamp coordinates skip-delay across instances. 300ms threshold for “recently open.” |
| Does not guarantee | Hover intent detection by velocity. Use createHoverIntent for direction-aware logic. |
| Known edge cases | Long delays interact with scroll: a tooltip scheduled to open may fire after the trigger has scrolled out of view. |
| Test coverage | untested |
dialog-aria
| Aspect | Detail |
|---|
| Guarantees | Pure functions returning ARIA prop bags. Includes data-state for visual state. |
| Does not guarantee | Focus management. focus-trap is a separate concern. |
| Known edge cases | The functions return new objects on every call. Memoize in the consumer if reference equality matters. |
| Test coverage | untested |
aria-manager
| Aspect | Detail |
|---|
| Guarantees | Type-safe ARIA attribute mutation with spec validation. Auto-generates IDs for relationships when missing. |
| Does not guarantee | Cross-element relationship cleanup. Removing one side of a relationship leaves the other side referencing a now-missing id. |
| Known edge cases | Setting an invalid attribute value throws. Consumers must catch or pre-validate. |
| Test coverage | untested |
Serialization Layer
EditorSerializer interface
| Aspect | Detail |
|---|
| Guarantees | id: string, extensions: readonly string[], deserialize(input): DeserializeResult, serialize(blocks, frontmatter?): string. |
| Does not guarantee | Round-trip equivalence. Adapter implementations claim that themselves. |
| Known edge cases | Extensions are readonly arrays; consumers must not mutate. |
| Test coverage | tested via concrete adapter tests |
mdxSerializer
| Aspect | Detail |
|---|
| Guarantees | Round-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 guarantee | MDX expressions with side effects. Custom remark plugins. |
| Known edge cases | JSX attributes parsed by jsxAttributesToProps flatten complex expressions to strings. Consumer reconstruction is consumer’s problem. |
| Test coverage | tested at packages/ui/src/primitives/serializer-mdx.test.ts and packages/ui/src/primitives/serializer-mdx-fixtures.test.ts |
Composites toMdx (legacy)
| Aspect | Detail |
|---|
| Guarantees | Renders heading, text, blockquote, list, divider, grid, composite:* to markdown. |
| Does not guarantee | Inline mark preservation. Frontmatter. Round-trip parsing. quote, input, button, image (drops to comment). |
| Known edge cases | MAX_DEPTH=50 differs from instantiateBlocks maxDepth=10. |
| Test coverage | tested at packages/composites/src/serializer.test.ts |
Composites Layer
Composite manifest
| Aspect | Detail |
|---|
| Guarantees | Zod-validated. id matches /^[a-z0-9-]+$/. cognitiveLoad is integer 1–10. category is free-form string. |
| Does not guarantee | Backward-compat for old composites missing solves, appliesWhen, usagePatterns. They parse fine; consumers must handle the optional fields. |
| Known edge cases | Display name with non-kebab characters fails serializeToComposite validation. Names should yield valid kebab ids. |
| Test coverage | tested at packages/composites/src/manifest.test.ts |
bridge
| Aspect | Detail |
|---|
| Guarantees | instantiateBlocks 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 guarantee | Determinism across runs. Each call produces fresh UUIDs. |
| Known edge cases | Nested composite without explicit parentId inherits the parent’s new id. Missing composite resolves to a fallback text block “Unknown composite: {id}”. |
| Test coverage | tested at packages/composites/src/bridge.test.ts |
registry
| Aspect | Detail |
|---|
| Guarantees | O(1) get, in-memory Map keyed by manifest.id. search returns fuzzy-scored matches sorted descending. |
| Does not guarantee | Persistence. Cross-process visibility. |
| Known edge cases | Imports fuzzyScore from @rafters/ui (architectural violation, see Known Gaps). |
| Test coverage | tested at packages/composites/src/registry.test.ts |
rules
| Aspect | Detail |
|---|
| Guarantees | matchRules returns { matched, missing, extra, compatible }. Compatibility is nominal name matching. Filter helpers findCompatibleConsumers and findCompatibleProducers. |
| Does not guarantee | Runtime data validation. Zod schemas in built-in-rules/ exist but are not invoked from blocks (see Known Gaps). |
| Known edge cases | Rule name collisions across unrelated codebases register as compatible. |
| Test coverage | tested 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.