evo
ClojureScript outliner with a tiny tree algebra.
Evo is a working structural text editor: nested blocks, inline markdown, page refs, images, math, multi-select, undo/redo, backlinks, and local-folder persistence. You can try it here: markusstrasser.org/evo-demo.
Before evo, I built an earlier Clojure version, now archived, and several JavaScript/Svelte prototypes. The kernel, renderer, and plugin system went through a few dozen versions in 2023/2024, before coding agents were useful for this kind of work. The repo is about 18K LoC. The structural-editing core is about 6.4K.
I did not set out to make a text editor. Evo fell out of a larger experiment: could user interfaces change directly from user events?
I now think that idea was too broad. Creative tools need stable primitives more than they need live meta-evolution. But the experiment left behind something useful: a small outliner kernel with a narrow mutation surface.
Coding agents are relevant here because Evo exposes editor behavior as data.
A user action becomes an intent map, a plugin turns that intent into ops, and the kernel validates and applies them through one transaction path. That gives coding agents a narrow surface: change an intent handler, inspect the emitted ops, and test the result without inventing DOM behavior.
I used to think of the kernel and plugin system as something like an IR for tree editing. That analogy is too heavy.
The simpler version is this: Evo compiles editor behavior down to three document operations: create-node, place, and update-node.
Quick start
Requires Node.js + npm, a JDK for shadow-cljs, and Babashka if you want the bb tasks.
macOS:
brew install node openjdk babashka
Debian/Ubuntu:
sudo apt install nodejs npm default-jdk
# install babashka separately if you want bb tasks
npm install
npm start # clean build + watch CLJS + watch CSS
# -> http://localhost:8080/blocks.html
npm run dev:fast skips the clean step when caches are healthy. npm run build produces a release build into public/js/blocks-ui.
What it does
| Area | Support |
|---|---|
| Blocks | nesting, indent/outdent, drag/drop, fold |
| Inline text | **bold**, _italic_, ==highlight==, ~~strike~~ |
| Links and refs | [[Page Name]], [label](target), evo://page/<name>, evo://journal/<iso-date> |
| Media | images with paste upload, resize handles, lightbox |
| Math | $inline math$, $$block math$$ through MathJax |
| Editor state | multi-select, undo/redo, autocomplete, backlinks |
| Persistence | local folder, no server, no account |
Core contract
Evo has one persistent document graph and one ephemeral session atom.
src/kernel/db.cljc stores the persistent document graph: nodes, parent-owned child vectors, and roots. The transaction pipeline attaches derived indexes after each write. src/shell/view_state.cljs owns cursor, selection, folding, autocomplete, and edit mode. That state changes too often to belong in the replayable document DB.
Canonical DB shape:
{:nodes {"a" {:type :block :props {:text "I am a node"}}}
:children-by-parent {:doc ["a"]}
:roots [:doc :trash]
:derived {:parent-of {"a" :doc}
:next-id-of {}
:prev-id-of {}
:index-of {"a" 0}}}
All durable document changes reduce to three ops:
{:op :create-node :id "a" :type :block :props {:text "I am a node"}}
{:op :place :id "a" :under :doc :at :last}
{:op :update-node :id "a" :props {:text "being updated"}}
The transaction contract is:
normalize -> validate -> apply -> derive
The DB source facts are:
:nodes
:children-by-parent
:roots
The transaction pipeline derives lookup maps after each write:
:parent-of
:index-of
:prev-id-of
:next-id-of
Queries such as (q/parent-of db id) and (q/next-sibling db id) read those maps. Plugins can add derived views too; backlinks are implemented as one.
Plugins turn user intent into kernel ops.
Example intent:
{:type :indent
:id "node-B"}
The indent plugin reads the current tree, finds node-B's previous sibling, and emits one placement:
{:op :place :id "node-B" :under "node-A" :at :last}
That is the boundary:
(db, intent) -> {:ops [...]
:session-updates {...}
:effects [...]}
Plugins do not mutate the DB. They return data. The executor sends ops through the transaction pipeline, applies session updates, and runs effects. Effects are [kind arg-map] tuples for side effects that are not document ops — system clipboard writes, file deletion. Handlers stay pure; the executor owns the I/O.
Page refs and backlinks stay out of the kernel. Plugins interpret the intent, emit normal ops, and add derived views when they need faster reads.
Structural editing means tree edits, not visual whitespace. Tab and Shift+Tab change parent-child relationships. Indent moves a block under its previous sibling; outdent moves it after its parent. In an outline, parentage carries meaning; changing one block's level should not silently reparent its siblings.
Before Shift+Tab:
Parent
Child A
Child B
After Shift+Tab on Child A:
Parent
Child B
Child A
contenteditable owns live text while you type. Evo commits text back to the document graph at controlled boundaries instead of writing every keystroke into the DB. That avoids cursor and render churn. Main implementation: src/components/block.cljs and src/shell/view_state.cljs.
src/kernel/ must not import src/shell/, src/components/, or src/keymap/. Reads go through src/kernel/query.cljc.
Architecture
Runtime path:
DOM event
-> intent map
-> plugin handler
-> kernel ops
-> transaction pipeline
-> canonical DB + derived indexes
-> parser/render
-> DOM
src/kernel/ owns the document machine. src/plugins/ compiles intents into ops, session updates, and effects. src/shell/ wires the browser/runtime path. src/components/ owns UI behavior. src/parser/ turns text into AST, and src/shell/render/ turns AST tags into hiccup.
The extension surface has three registries:
| Registry | Adds | File |
|---|---|---|
| Intent | editing/navigation behavior | kernel.intent/register-intent! |
| Derived index | materialized read views | kernel.derived-registry/register! |
| Render | AST tag rendering | shell.render-registry/register-render! |
Each registry is a defonce atom plus validation and dispatch. Re-registering a key replaces the old handler, which keeps hot reload and test fixtures simple. Bootstrapping goes through src/plugins/manifest.cljc, src/shell/render_manifest.cljc, and src/shell/editor.cljs.
The intent registry maps intent keywords such as :indent, :navigate-to-page, and :collapse to validated handlers that return {:ops ... :session-updates ... :effects ...}. Those ops flow through src/kernel/transaction.cljc; session updates land in src/shell/view_state.cljs. The derived-index registry owns materialized views under db[:derived]; src/plugins/backlinks_index.cljc is the canonical example. The render registry maps AST tags to pure hiccup handlers; unknown tags throw instead of silently degrading.
Where a change belongs:
| Change | Touch |
|---|---|
| New inline syntax | parser + render handler |
| New editing behavior | plugin intent handler |
| New materialized read view | derived-index plugin |
| New document invariant | kernel |
Two non-registry files matter:
| File | Owns |
|---|---|
src/shell/view_state.cljs |
cursor, selection, folds, edit mode, drag state |
src/shell/log.cljs |
append-only transaction journal |
Undo, persistence, and replay all read the same transaction history.
Multi-step edits, where one step needs the result of the previous one, run inline in the handler: it interprets ops against a scratch DB, inspects the result, and emits the final op set. The runtime still sees one atomic edit.
Backlinks show the split:
| Part | File |
|---|---|
| Derived index | src/plugins/backlinks_index.cljc |
| UI panel | src/components/backlinks.cljs |
| Navigation intent | src/plugins/pages.cljc |
| Page-ref rendering | src/shell/render/page_ref.cljs |
Inline content is parsed into a uniform AST shape:
[:tag {attrs} content]
Common tags include :doc, :text, :bold, :italic, :highlight, :strikethrough, :math-inline, :math-block, :link, :page-ref, and :image. See src/parser/ast.cljc.
Where things go:
- components render views and panels; they do not parse content or own per-tag logic
- render handlers live per AST tag, not per block-level mode
- session state does not go in the document DB
src/kernel/must not importsrc/shell/,src/components/, orsrc/keymap/
Project layout
Most editor behavior lives in src/kernel/ and src/plugins/.
| Path | Approx. LoC | Owns |
|---|---|---|
src/components/ |
~4.3k | UI, especially block.cljs |
src/plugins/ |
~3.7k | intent handlers and derived-index plugins |
src/kernel/ |
~3.2k | pure document model, ops, transaction pipeline, queries |
src/shell/ |
~3.0k | startup, storage, executor, view state, URL sync |
src/utils/ |
~1.8k | DOM, text, cursor, image, helper code |
src/spec/ |
~0.8k | FR/spec runner and registry glue |
src/parser/ |
~0.8k | inline text parsing into AST nodes |
src/keymap/ |
~0.2k | keybinding tables and dispatch glue |
test/ |
- | unit, property, integration, and Playwright E2E tests |
resources/ |
- | FR registry, failure modes, seed data |
public/ |
- | HTML, CSS, MathJax shim, build output |
docs/ |
- | structural editing, rendering, dispatch, testing docs |
Tests
bb test # full unit/property suite (CLJS via shadow-cljs)
bb test:view # hiccup-only tier (<1s)
bb test:kernel # kernel tests only
bb check # lint + arch verification + compile
npm run test:e2e:smoke # Playwright smoke (~10s)
npm run test:e2e # full Playwright suite (~4min)
Non-goals
- Not a PKM product. Structured note-taking is a low-ROI trap for this project. Evo cares about the outliner mechanics, not the life system around them.
- Not a packaged library. There is no Clojars artifact and no API stability promise. Reuse the kernel if it fits, but this repo does not pretend to be a stable dependency.
- Not full editor parity. Evo aims for a solid structural editing spec, not every feature from rich-text editors or PKM apps. I skipped block embeds and page embeds on purpose.
- No universal editor primitive. Evo abstracts over tree editing, not text, video, audio, CAD, or every creative domain.
Related
These comparisons are about architecture, not product scope:
- Logseq: Closest on outliner semantics, but its core mutations ride on Datascript transactions and app-level outliner ops. Evo makes the mutation algebra itself smaller and more explicit.
- ProseMirror: Centers on schema, transactions, and
Steptransforms over a rich document model. Evo keeps a simpler tree DB and compiles plugin behavior down to three ops. - Slate: Also operation-based, but the mutable
Editorobject plus normalization/history plugins carry much of the behavior. Evo puts those semantics in a standalone kernel instead of editor-instance methods. - Tiptap: Mainly an extension layer over ProseMirror's transaction and plugin system. Evo owns the kernel directly instead of wrapping another editor core.
- xi-editor: Strong core/plugin split too, but for a rope-based text engine with RPC plugins. Evo is a structural tree kernel first, not a text-buffer architecture.
Further reading
I no longer think creative tools should evolve from raw event streams. Creative work depends on stable primitives. AI can patch small parts of a tool, but the outer loop still needs a designed interface, a clear domain model, and tests.
These are the references behind that earlier interface experiment:
- Ben Shneiderman, “Direct Manipulation: A Step Beyond Programming Languages” (1983)
https://www.cs.umd.edu/users/ben/papers/Shneiderman1983Direct.pdf - Brad A. Myers, “A Brief History of Human Computer Interaction Technology” (1998)
https://www.cs.cmu.edu/~amulet/papers/uihistory.tr.html - Ben Shneiderman, “Creativity Support Tools: Accelerating Discovery and Innovation” (2007)
https://www.cs.umd.edu/users/ben/papers/Shneiderman2007Creativity.pdf - Donald A. Norman, The Design of Everyday Things, revised and expanded edition (2013)
https://jnd.org/books/the-design-of-everyday-things-revised-and-expanded-edition/ - Donald A. Norman, Things That Make Us Smart: Defending Human Attributes in the Age of the Machine (1994)
https://jnd.org/books/things-that-make-us-smart-defending-human-attributes-in-the-age-of-the-machine/ - Donald T. Campbell, “Assessing the Impact of Planned Social Change” (1976/1979)
https://www.humanlearning.systems/uploads/08%20Assessing%20the%20Impact%20of%20Planned%20Social%20Change.pdf