Jelajahi Sumber

docs: update all READMEs; add pipeline perf test

Packages:
  - editor/README: Block → EditorBlock component name, EditorFocusTarget
    union type for focused prop, hidden-at-rest grip description
  - root README: fix monorepo tree (6 pages, not 10), grip not bullet

Dev app:
  - editor-block.vue: fix "one line" → "a block"; add resolveAsset prop,
    pattern-update, content-change-op, first-para-height events
  - editor.vue: focused prop type boolean → EditorFocusTarget;
    add debug and testMode props
  - dev/README: Block → EditorBlock, 6 pages not 10

Tauri app:
  - README: replace generic template with Enesis-specific docs
  - Welcome.md: remove default Tauri welcome file

Testing:
  - Add parse-pipeline.perf.test.ts: 4 tests asserting full decoration
    pipeline (apply + decorations) averages under 16ms per keystroke
Zander Hawke 13 jam lalu
induk
melakukan
9fdc006fda

+ 11 - 15
README.md

@@ -38,28 +38,24 @@ The multi-block architecture is the other major friction point. Each `EditorBloc
 │       │   │   ├── LiveExample.vue     Editable demo wrapper with source view
 │       │   │   ├── LiveExample.vue     Editable demo wrapper with source view
 │       │   │   └── AppLogo.vue         Enesis brand mark
 │       │   │   └── AppLogo.vue         Enesis brand mark
 │       │   ├── pages/
 │       │   ├── pages/
-│       │   │   ├── index.vue           Overview
-│       │   │   ├── basic-editing.vue   Headings, paragraphs, rules
-│       │   │   ├── inline-marks.vue    Bold, italic, code, strike, highlight
-│       │   │   ├── tasks.vue           Task states & priorities
-│       │   │   ├── blockquotes.vue     Blockquotes & callouts
-│       │   │   ├── links.vue           Markdown links
-│       │   │   ├── refs-tags.vue       Page refs, block refs, tags
-│       │   │   ├── code-blocks.vue     Fenced code blocks
-│       │   │   ├── properties.vue      Key::value properties & dates
-│       │   │   └── math.vue            Inline & display LaTeX
-│       │   ├── App.vue                 Shell layout (Nuxt UI) with sidebar
-│       │   ├── main.ts                 App bootstrap (10 routes)
+│       │   │   ├── index.vue           Overview / key concepts
+│       │   │   ├── editor.vue          Multi-block Editor shell
+│       │   │   ├── editor-block.vue    Standalone EditorBlock demo
+│       │   │   ├── toolbar.vue         Toolbar integration
+│       │   │   ├── suggestion-menu.vue Pattern completion menu
+│       │   │   └── themes.vue          Theme presets
+│       │   ├── App.vue                 Shell layout with sidebar
+│       │   ├── main.ts                 App bootstrap (6 routes)
 │       │   └── style.css               Tailwind v4 + Nuxt UI theme
 │       │   └── style.css               Tailwind v4 + Nuxt UI theme
-│       ├── public/                     Static assets
+│       ├── public/                     Static assets (icons.svg sprite, favicon)
 │       └── vite.config.ts              Vite with Nuxt UI plugin
 │       └── vite.config.ts              Vite with Nuxt UI plugin
 ├── packages/
 ├── packages/
 │   └── editor/                Core headless engine
 │   └── editor/                Core headless engine
 │       ├── src/
 │       ├── src/
-│       │   ├── index.ts               Public API (Block + Editor + EditorToolbar components)
+│       │   ├── index.ts               Public API (Editor + EditorBlock + EditorToolbar + EditorSuggestionMenu)
 │       │   ├── components/
 │       │   ├── components/
-│       │   │   ├── EditorBlock.vue     Self-contained ProseMirror block editor with draggable bullet
+│       │   │   ├── EditorBlock.vue     Self-contained ProseMirror block editor with draggable grip
 │       │   │   ├── Editor.vue          Multi-block shell with insertion zones, drag-to-reorder, undo/redo
 │       │   │   ├── Editor.vue          Multi-block shell with insertion zones, drag-to-reorder, undo/redo
 │       │   │   ├── EditorInsertionZone.vue   32px hit target between blocks, hover-reveal, drag target
 │       │   │   ├── EditorInsertionZone.vue   32px hit target between blocks, hover-reveal, drag target
 │       │   │   └── EditorToolbar.vue   Shared toolbar bound to active block
 │       │   │   └── EditorToolbar.vue   Shared toolbar bound to active block

+ 2 - 2
apps/dev/README.md

@@ -1,6 +1,6 @@
 # @enesis/dev
 # @enesis/dev
 
 
-Documentation-style development sandbox for the Enesis Editor. A Vite + Vue 3 app that exercises every `@enesis/editor` `<Block>` feature through live, editable examples organized across 10 catalog pages.
+Documentation-style development sandbox for the Enesis Editor. A Vite + Vue 3 app that exercises every `@enesis/editor` feature through live, editable examples organized across 6 catalog pages.
 
 
 ## Quick Start
 ## Quick Start
 
 
@@ -37,7 +37,7 @@ App.vue                     Shell layout — UHeader + UPage + sidebar navigatio
 └── pages/themes.vue        Theme presets
 └── pages/themes.vue        Theme presets
 
 
 components/
 components/
-├── LiveExample.vue         <Block> wrapper with title, description, source code panel
+├── LiveExample.vue         <EditorBlock> wrapper with title, description, source code panel
 └── AppLogo.vue             Enesis brand mark
 └── AppLogo.vue             Enesis brand mark
 ```
 ```
 
 

+ 9 - 5
apps/dev/src/pages/editor-block.vue

@@ -64,7 +64,7 @@ function onTagClick({ tag }: { tag: string }) {
       <div class="space-y-8">
       <div class="space-y-8">
         <section class="space-y-4">
         <section class="space-y-4">
           <p class="text-sm text-muted leading-relaxed">
           <p class="text-sm text-muted leading-relaxed">
-            Each <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">EditorBlock</code> manages one line of markdown in its own ProseMirror instance.
+            Each <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">EditorBlock</code> manages a block of markdown in its own ProseMirror instance.
             Decorations render bold, italic, links, tables, code blocks, callouts, references, tags, math, and properties.
             Decorations render bold, italic, links, tables, code blocks, callouts, references, tags, math, and properties.
           </p>
           </p>
 
 
@@ -96,13 +96,14 @@ function onTagClick({ tag }: { tag: string }) {
               </thead>
               </thead>
               <tbody>
               <tbody>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">content</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Markdown content (v-model)</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">content</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Markdown content (v-model)</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focused</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">boolean</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">false</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Auto-focus on mount</td></tr>
+                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focused</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">boolean</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">false</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Focus the block and show active decorations; re-focuses when the prop changes</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">cursorPosition</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'start' \| 'end' \| number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor placement on focus</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">cursorPosition</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'start' \| 'end' \| number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor placement on focus</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">markerMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview' \| 'always-visible'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Decorator visibility mode</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">markerMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview' \| 'always-visible'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Decorator visibility mode</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">debug</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Debug namespace filter</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">debug</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Debug namespace filter</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render with dashed underline</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render with dashed underline</td></tr>
-                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across all blocks</td></tr>
+                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across all blocks</td></tr>
+                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolveAsset</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">(file: File) => Promise&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Upload handler — resolves dropped/pasted files to a URL</td></tr>
               </tbody>
               </tbody>
             </table>
             </table>
           </div>
           </div>
@@ -123,14 +124,17 @@ function onTagClick({ tag }: { tag: string }) {
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">change</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Content changed</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">change</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Content changed</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focus</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ view, handlers }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block gained focus</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focus</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ view, handlers }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block gained focus</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">blur</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block lost focus</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">blur</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Block lost focus</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">selection-change</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ from, to, empty }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Selection changed</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">selection-change</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ from, to, empty }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Selection changed</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">content-change-op</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ blockId, previousContent, newContent, timestamp }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Content change metadata for history tracking</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">first-para-height</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Height of first paragraph, for drag-handle alignment</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">split</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ before, after }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Enter split the block</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">split</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ before, after }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Enter split the block</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">merge-previous</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Backspace at start</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">merge-previous</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Backspace at start</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">delete-if-empty</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Backspace on empty block</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">delete-if-empty</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Backspace on empty block</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">indent / outdent</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Tab / Shift+Tab</td></tr>
                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">indent / outdent</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Tab / Shift+Tab</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">arrow-up-from-start</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number?</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor at first line</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">arrow-up-from-start</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number?</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor at first line</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">arrow-down-from-end</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number?</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor at last line</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">arrow-down-from-end</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number?</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Cursor at last line</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-open</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternOpenPayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Trigger detected (/, [[, \#)</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-open</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternOpenPayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Trigger detected (/, [[, ((, #)</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-update</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternUpdatePayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Pattern query/range updated as user types</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-close</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternClosePayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Pattern session ended</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">pattern-close</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternClosePayload</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Pattern session ended</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">page-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ pageName, alias? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a [[page]] chip</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">page-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ pageName, alias? }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a [[page]] chip</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">block-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ blockId }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a ((block)) chip</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">block-ref-clicked</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">{ blockId }</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Clicked a ((block)) chip</td></tr>

+ 5 - 3
apps/dev/src/pages/editor.vue

@@ -163,14 +163,16 @@ function onTagClick({ tag }: { tag: string }) {
               </thead>
               </thead>
               <tbody>
               <tbody>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">content</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Full document markdown (v-model)</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">content</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Full document markdown (v-model)</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focused</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">boolean</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">false</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Focus first block on mount</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">markerMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview' \| 'always-visible'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Decorator visibility mode</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">focused</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">EditorFocusTarget</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Focus target on mount: <code>true</code> (first block), <code>"first-block"</code>, <code>"last-block"</code>, <code>"first-zone"</code>, <code>"last-zone"</code>, or <code>{ kind: "block"; id }</code> / <code>{ kind: "zone"; index }</code></td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">markerMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview' \| 'always-visible'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'live-preview'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Decorator visibility mode</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">debug</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">string</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Debug namespace filter</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">theme</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">ThemeInput</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">CSS-variable theme preset</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">theme</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">ThemeInput</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">CSS-variable theme preset</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">keyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">KeyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">default</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Swappable keyboard handler</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">keyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">KeyBinding</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">default</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Swappable keyboard handler</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">extraPatterns</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">PatternSpec[]</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Custom suggestion triggers</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render dashed</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefs</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">Set&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Known refs — missing entries render dashed</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across blocks</td></tr>
                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolvedRefsVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Bump to re-check refs across blocks</td></tr>
-                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolveAsset</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">(file) => Promise&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Upload handler — drops/pastes insert as ![]()</td></tr>
+                   <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">resolveAsset</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">(file) => Promise&lt;string&gt;</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Upload handler — drops/pastes insert as ![]()</td></tr>
+                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">testMode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">boolean</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">false</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Expose window.__testUtils__ for Playwright E2E tests</td></tr>
               </tbody>
               </tbody>
             </table>
             </table>
           </div>
           </div>

+ 58 - 4
apps/tauri/README.md

@@ -1,7 +1,61 @@
-# Tauri + Vue + TypeScript
+# Enesis
 
 
-This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+A warm, literary journal app. Built with Tauri, Nuxt UI, and the `@enesis/editor` block markdown engine.
 
 
-## Recommended IDE Setup
+Daily notes are flat markdown files (`journals/YYYY-MM-DD.md`) — readable anywhere, no lock-in. A SQLite index provides full-text search and backlinks.
 
 
-- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+The visual identity (Hearthside theme) leans into warmth: Lora serif, clay primary, stone neutrals, earthy semantics.
+
+## Quick start
+
+```bash
+pnpm install
+pnpm generate  # static build
+pnpm tauri dev  # or: pnpm tauri build
+```
+
+## Stack
+
+| Layer | Technology |
+|---|---|
+| Desktop shell | Tauri v2 (Rust) |
+| Frontend | Nuxt 3 + Vue 3 + TypeScript |
+| UI framework | Nuxt UI v4 |
+| Editor engine | `@enesis/editor` (Lezer-based block markdown) |
+| Index | SQLite via `tauri-plugin-sql`, FTS5 |
+| File watching | `notify` crate |
+
+## Theme — Hearthside
+
+Warm, earthy, literary. Lora serif throughout, clay primary, orange secondary, stone neutral base, rose/emerald/amber/sky for semantics. Configured in `app.config.ts` and `assets/main.css`.
+
+## Development
+
+```bash
+pnpm dev              # Vite dev server
+pnpm tauri dev        # Tauri desktop dev
+pnpm generate         # Static pre-render (for Tauri)
+pnpm test:e2e         # Playwright E2E (in packages/editor)
+```
+
+## Project structure
+
+```
+src/
+  assets/main.css              # Tailwind + theme tokens + font
+  app.config.ts                # Nuxt UI color mappings
+  components/
+    JournalDay.vue             # One Editor per day
+    JournalView.vue            # Reverse-chronological feed
+    TimelineRail.vue           # Density dot timeline
+    AppRightSidebar.vue        # References + search
+  layouts/default.vue          # Sidebars + editor shell
+  composables/                 # useFileSystem, useDates, etc.
+  stores/                      # Pinia: workspace, ui
+  lib/                         # Indexer, schema, parse helpers
+src-tauri/
+  src/lib.rs                   # Rust commands + plugins
+  icons/                       # App icons (clay block logo)
+public/
+  favicon.svg                  # Clay block stacking logo
+```

+ 0 - 47
apps/tauri/Welcome.md

@@ -1,47 +0,0 @@
-* # Welcome to Enesis Editor
-
-  Each **block** between blank lines is its own independent editor — click anywhere to start typing.
-
-  * ## Text Formatting
-
-    Try **bold**, *italic*, `code`, ~~strikethrough~~, ^^highlight^^, and $\LaTeX$ math.
-
-  * ## References
-
-    [[Known Page]] resolves. [[Missing Page]] shows via dashed border.
-
-    ((unknown-block-id))
-
-    Use #tags for categorization.
-
-  * ## Tasks
-
-    * TODO Write the documentation
-    * DOING Implement the parser
-    * DONE Set up the build pipeline
-
-  * ## Table
-
-    | Feature | Status |
-    | --- | --- |
-    | Blocks | Done |
-    | Tables | Done |
-
-
-  * ## Code
-
-    ```typescript
-    function greet(name: string): string {
-      return `Hello, ${name}!`
-    }
-    ```
-
-  * ## Callout
-
-    > [!NOTE] Useful info for the reader
-    > Plus multi-line functionality
-
-  * ## Properties
-
-    author:: Enesis Editor
-    version:: 0.1.0

+ 7 - 14
packages/editor/README.md

@@ -14,14 +14,14 @@ Requires peer dependencies: `vue`, `@nuxt/ui`, `@vueuse/core`, `pinia`.
 
 
 ```vue
 ```vue
 <script setup lang="ts">
 <script setup lang="ts">
-import { Block } from "@enesis/editor"
+import { Editor, EditorBlock } from "@enesis/editor"
 import { ref } from "vue"
 import { ref } from "vue"
 
 
 const content = ref("# Hello\n\nThis is **bold** text.\n\n> [!NOTE] A callout")
 const content = ref("# Hello\n\nThis is **bold** text.\n\n> [!NOTE] A callout")
 </script>
 </script>
 
 
 <template>
 <template>
-  <Block v-model:content="content" focused cursor-position="end" />
+  <EditorBlock v-model:content="content" :focused="true" cursor-position="end" />
 </template>
 </template>
 ```
 ```
 
 
@@ -30,10 +30,10 @@ const content = ref("# Hello\n\nThis is **bold** text.\n\n> [!NOTE] A callout")
 ```ts
 ```ts
 import EnesisEditor from "@enesis/editor"
 import EnesisEditor from "@enesis/editor"
 app.use(EnesisEditor)
 app.use(EnesisEditor)
-// → <Block /> registered globally
+// → <EditorBlock /> registered globally
 ```
 ```
 
 
-## `<Block>` API
+## `<EditorBlock>` API
 
 
 ### Props
 ### Props
 
 
@@ -85,9 +85,9 @@ The `Editor` component manages a list of blocks with insertion zones between the
 ### Props
 ### Props
 
 
 | Prop | Type | Default | Description |
 | Prop | Type | Default | Description |
-|---|---|---|---|
+|---|---|---|---|---|
 | `content` (v-model) | `string` | required | Full document markdown (all blocks serialized). |
 | `content` (v-model) | `string` | required | Full document markdown (all blocks serialized). |
-| `focused` | `boolean` | `false` | Whether the first block receives focus on mount. |
+| `focused` | `"first-block" \| "last-block" \| "first-zone" \| "last-zone" \| { kind: "block"; id: string } \| { kind: "zone"; index: number } \| true` | — | Where to place focus on mount. String values target positions; object values target specific blocks or zones; `true` focuses the first block. |
 | `markerMode` | `"live-preview" \| "always-visible"` | `"live-preview"` | When to show markdown delimiters. |
 | `markerMode` | `"live-preview" \| "always-visible"` | `"live-preview"` | When to show markdown delimiters. |
 | `debug` | `string` | — | Namespace filter for debug logs. |
 | `debug` | `string` | — | Namespace filter for debug logs. |
 | `theme` | `ThemeInput` | — | CSS-variable theme preset or overrides (see `theme.ts`). |
 | `theme` | `ThemeInput` | — | CSS-variable theme preset or overrides (see `theme.ts`). |
@@ -337,7 +337,7 @@ Each pattern spec defines:
 
 
 ## Drag-to-Reorder
 ## Drag-to-Reorder
 
 
-Each block has a draggable bullet in its left gutter. The bullet is always visible, transitions from muted to primary on hover, and grows (size-6 → size-7) with primary color while being dragged. Drop targets are the insertion zones between blocks — a primary-colored bar appears on the zone during a valid hover. Dragging to a zone before the first block, between any two blocks, or after the last block inserts the block at that position.
+Each block has a draggable grip in its left gutter. The grip is hidden at rest (`opacity-0`), fades in on block hover, and transitions from muted to primary color while being dragged. Drop targets are the insertion zones between blocks — a primary-tinted bar appears on the zone during a valid hover. Dragging to a zone before the first block, between any two blocks, or after the last block inserts the block at that position.
 
 
 - **Native HTML5 drag** — no external drag library required
 - **Native HTML5 drag** — no external drag library required
 - **Zone-based drop** — drop on any insertion zone, not on the block itself
 - **Zone-based drop** — drop on any insertion zone, not on the block itself
@@ -352,13 +352,6 @@ import type { MoveBlockOp } from "@enesis/editor"
 const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
 const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
 ```
 ```
 
 
-```typescript
-import { moveBlock } from "@enesis/editor"
-import type { MoveBlockOp } from "@enesis/editor"
-
-const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
-```
-
 ## Development
 ## Development
 
 
 ```bash
 ```bash

+ 55 - 0
packages/editor/src/lib/__tests__/parse-pipeline.perf.test.ts

@@ -0,0 +1,55 @@
+import { EditorState } from "prosemirror-state"
+import { EditorView } from "prosemirror-view"
+import { describe, test, expect } from "vitest"
+import { schema } from "@/lib/schema"
+import { createMarkdownDecorationsPlugin } from "@/composables/useMarkdownDecorations"
+
+const ITERATIONS = 50
+
+const SAMPLES: Record<string, string> = {
+  plain: "hello world",
+  bold: "this is **bold** and **more bold** text",
+  mixed:
+    "**bold** *italic* `code` ~~strike~~ ^^highlight^^ and [[Page Ref]] ((block-id)) #tag",
+  kitchenSink: [
+    "# Documentation\n\n",
+    "This is a **paragraph** with *multiple* `inline` ~~styles~~.\n\n",
+    "## Features\n\n",
+    "> [!TIP] Use callouts for **important** notes\n\n",
+    "TODO Add [[Page Reference]] and ((block-123)) support\n\n",
+    "priority:: high\nauthor:: John\n",
+  ].join(""),
+}
+
+function makeDoc(content: string) {
+  const textNode = content ? schema.text(content) : undefined
+  const para = textNode
+    ? schema.node("paragraph", {}, [textNode])
+    : schema.node("paragraph")
+  return schema.node("doc", {}, [para])
+}
+
+describe("per-keystroke pipeline budget", () => {
+  for (const [name, content] of Object.entries(SAMPLES)) {
+    test(`${name} averages under 16ms`, () => {
+      const { plugin } = createMarkdownDecorationsPlugin()
+      const state = EditorState.create({
+        doc: makeDoc(content),
+        schema,
+        plugins: [plugin],
+      })
+      const view = new EditorView(document.createElement("div"), { state })
+
+      const start = performance.now()
+      for (let i = 0; i < ITERATIONS; i++) {
+        const pos = view.state.doc.content.size
+        view.dispatch(view.state.tr.insertText("x", pos))
+      }
+      const avg = (performance.now() - start) / ITERATIONS
+
+      view.destroy()
+
+      expect(avg).toBeLessThan(16)
+    })
+  }
+})