outl docs
Clients and shared logic
outl has multiple clients today (TUI, mobile, desktop) and more coming (plugins).
They all sit on top of the same workspace and the same op log.
To keep them honest, we route every workspace operation through one shared crate: outl-actions.
The TS+Solid frontends share @outl/shared (crates/outl-frontend-shared) for everything pure (DTO types, <MarkdownInline />, paste helpers, copy wrappers, autocomplete).
The stack
┌──────────────────────────────────────────────────────────────┐
│ Clients │
│ outl-cli outl-tui outl-mobile outl-desktop …plugins │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ outl-actions │
│ block · tree · todo · journal · outline · page · backlinks │
│ history (bounded undo/redo stacks + .md snapshot restore) │
│ sync (SyncEngine: reload workspace, reproject page, │
│ snapshot peer jsonls, scan for orphan .md) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ outl-md (.md parse/render, sidecar, matching, │
│ inline tokens, outline_ops) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ outl-core (CRDT, op log, storage trait) │
└──────────────────────────────────────────────────────────────┘
What lives where
| Concern | Crate |
|---|---|
| Op log, tree CRDT, storage trait | outl-core |
.md parse / render, sidecar | outl-md |
| Workspace mutations (edit, indent, todo, delete, journal render) | outl-actions |
Committed-mutation undo / redo (snapshot stacks + .md restore via reconcile) | outl-actions::history |
| Code-block execution (runtimes + orchestration) | outl-exec |
Cross-client “run a fence” glue (run_code_block) | outl-actions::exec |
Tauri command bodies and the one declaration of the command surface (wrappers/catalog.rs), wire DTOs, plugin thread (Boa !Send), AppHost / StorageRootProvider traits — shared by outl-desktop and outl-mobile src-tauri. A client’s commands/<module>.rs is one macro invocation; a client takes a whole module or declares the gap, pinned by tests/command_parity.rs | outl-tauri-shared |
The post-mutation commit sequence — undo snapshot, backlink invalidation, peer announce, .md projection — as commit_page + the CommitHooks trait, callable from any client holding a &mut Workspace | outl-actions |
| TUI: keymaps, modes, overlays, in-flight AST manipulation | outl-tui |
| Desktop: FS watcher, settings IO, Solid frontend (3-pane, OS-standard shortcuts) | outl-desktop |
Mobile: iCloud container resolution, iOS-native bridges (NSMetadataQuery, BGTaskScheduler), Solid frontend | outl-mobile |
| CLI subcommands | outl-cli |
| Chord catalog, and which client performs which action | outl-shortcuts |
Workspace locks on open
Every client that opens a workspace takes the same two advisory locks, through the same outl-core API.
No client implements the protocol itself.
| Lock | Scope | Taken by |
|---|---|---|
<root>/.outl/.lock | shared — every well-behaved opener piles on | outl_core::WorkspaceLock::acquire |
<root>/ops/.lock-<actor> | exclusive — one process per actor id | outl_core::ActorWriteLock::try_acquire |
The shared lock is not mutual exclusion; it is a presence signal.
outl compact --apply answers “is anyone in this workspace?” by taking that same file exclusively, because compaction renames each ops-<actor>.jsonl and a live client holds in-memory byte offsets into the pre-compaction layout.
A client that holds nothing lets that gate pass, and every later index-driven read in that session seeks into a renumbered file.
The exclusive per-actor lock is the stated precondition of JsonlStorage::append_ops: one writer per ops-<actor>.jsonl, ever.
The CLI and TUI resolve theirs through outl_ws::open; the desktop and mobile clients resolve theirs through outl_tauri_shared::workspace_open::open_workspace_at, which is the single writer of the client’s workspace_guards slot.
It installs the new guards only after the open succeeds, which is also what releases the previous workspace’s.
A client never acquires or drops one of these itself.
Both locks are flock-based: advisory, machine-local, and released by the OS if a process dies.
They arbitrate between processes on one machine and can never arbitrate between devices — the per-device actor binding (outl-core’s DeviceStore) is what handles that.
What happens when the actor lock is contended
The CLI and TUI mint a fresh ephemeral actor and write to a brand-new ops-<ephemeral>.jsonl (outl_core::resolve_write_actor).
The GUI clients refuse the open instead, and the difference is not an oversight.
A GUI’s HlcGenerator is built in setup(), before a workspace is picked, and every op it stamps carries that generator’s actor for the life of the process.
Swapping only the storage actor would write ops stamped with the device actor into the ephemeral file and leave two live generators sharing one actor id — two generators that tick in the same millisecond emit the identical (time, counter, actor) triple, which is op identity, so Workspace::apply’s dedup silently drops one of the two ops.
A fallback that loses ops is worse than the collision it dodges.
In practice the refusal is a safety net rather than a path users hit: the desktop is single-instanced by tauri-plugin-single-instance, mobile is single-instanced by the OS, and the CLI / TUI resolve a different actor for the same workspace (DeviceStore::device_actor reads <config>/actor, DeviceStore::actor_for_instance reads <config>/actors/<key>), so they never contend with a GUI.
When a client can’t do the thing
Not every client performs every action, and that is fine. What is not fine is the user pressing a key and getting nothing, with no way to tell a gap from a bug.
outl_shortcuts::support(action) is the single owner of that fact — one exhaustive match, so a new Action variant does not compile until all three clients have declared what they do with it.
The lesser states (Partial, Missing, NotApplicable) each carry the sentence shown to the user, written in the catalog so a client cannot invent its own wording.
The desktop wires it in lib/shortcuts.ts: a chord with no handler falls through without preventDefault (the textarea or the OS still gets the key) and surfaces the catalog’s sentence in the status line.
It used to emit console.warn instead, in a comment that called DevTools output something “the user sees”.
Full table, generated and test-pinned: client-parity.md.
Why this needed an owner at all. The fact had three homes and they disagreed:
shortcuts.mdlistedy rand:as desktop chords (neither has a handler), and mobile undo / redo as “toolbar” (mobile has neither — #14). Three hand-maintained copies, each stale in a different direction, and nothing that could fail. This is invariant 12: when you add a capability, enumerate who does not have it.
When to put logic in outl-actions
Yes if any of these are true:
- Two or more clients (today or in the next quarter) need the same op.
- The function takes only
Workspace + HlcGeneratorand returnsResult<_, ActionError>. - It produces ops by way of
Workspace::apply— no direct storage writes, no filesystem touches outsidejournal::write_md_atomic.
No if:
- It manipulates client UI state (selection, modes, toasts, focus, keymaps).
- It manipulates an in-flight
Vec<OutlineNode>that hasn’t been parsed back into a workspace yet. Those helpers live inoutl-md::outline_ops, re-exported through a one-liner shim atoutl-tui/src/outline_ops.rsbecause the mobile client needs them too. They’re workspace-free pure AST manipulation, so they sit inoutl-mdrather thanoutl-actions. - It’s storage-backend-specific (iCloud watcher, future ChronDB) — those implement
outl_core::Storagein the binary that needs them.
Surfacing parser warnings on every client
A user can drop a .md into the workspace by hand, paste an exported Roam/Logseq tree, or edit a file in vim before outl ever saw it.
When that file doesn’t match the outl dialect (e.g. starts with # heading, contains a free paragraph, or imports a markdown table), the parser does not drop content.
It preserves the line as a regular block and records the recovery in ParsedPage.warnings: Vec<outl_md::ParseWarning>.
Two families, not one
ParseWarningKind has six members and they do not mean the same thing.
Read warning.kind before wording anything.
| Kind | What actually happened |
|---|---|
unrecognized_block_marker | A line outside the dialect, kept verbatim as a block. |
remind_missing_anchor | remind:: every 1h — a repeat with nothing to repeat from. |
remind_invalid_time | remind:: 25:00 — not a wall-clock time the dialect recognises. |
remind_invalid_interval | remind:: 10am every 30s — below the 1min floor, or a bad unit. |
remind_invalid_stop | remind:: 10am until yesterday — unusable stop clause, dropped. |
remind_max_clamped | remind:: 10am max 50 — clamped down to the 10-fire ceiling. |
For a remind_* warning the line is in the dialect and was not preserved as a block: it is a valid remind:: property whose rule the scheduler could not take at face value.
The reminder either did not schedule or scheduled differently than written, which is the part a user needs told — “a line outside the dialect” describes neither.
This is open work. Every surface currently renders the single fixed sentence N line(s) outside outl dialect — preserved as blocks, with no reader of kind anywhere:
@outl/shared’s ParseWarningsBanner.tsx, the TUI’s view/warnings_banner.rs and its status chip in actions/lifecycle/loading.rs, and the sample output in docs/tui.md.
The wording is wrong for all five remind_* kinds, and was invisible for as long as it was because types.ts modelled only one of the six — closed now by outl-tauri-shared/tests/wire_enums.rs.
The fix is to split the count (N line(s) outside outl dialect, N reminder rule(s) outl could not schedule as written) and give each row a per-kind sentence, in the shared banner and the TUI together so the two cannot drift.
Every client surfaces these warnings to the user instead of pretending the file is clean:
| Client | Surface |
|---|---|
| TUI | Banner at the top of the outline + chip in the status line; ? opens the help overlay with the full list (line number + first 60 chars of raw). |
| Mobile / Desktop | <ParseWarningsBanner> from @outl/shared renders above the outline. Tap a row to scroll to the offending line in the raw view. |
| CLI | outl doctor lists every page with warnings and writes a structured row per warning to .outl/orphans.log. |
The shared entry point that bundles outline + warnings in one trip is outl_actions::outline::read_page_outline (and the workspace-aware variant read_page_outline_with_workspace) returning PageOutline { nodes, warnings }.
Tauri commands on mobile + desktop expose this directly; the TUI calls it via lifecycle::load_current.
The contract is intentionally non-blocking: a file with warnings is still editable, still saves cleanly (render normalises it to - <raw> on the next write), and never refuses to load.
Users decide when to clean up; outl never deletes content on their behalf.
Surfacing a page that stopped syncing
A parser warning says “outl kept a line that doesn’t match the dialect”. This is a different, louder condition: the page is no longer converging with your other devices.
It happens when a page’s .md holds content that exists in no op.
A YAML frontmatter fence the op log has never seen counts as such content, and reaches the user through this same refusal and the same recovery.
outl refuses to overwrite such a file (root CLAUDE.md invariant 8, RFC 0210), because that write deletes the content for good.
The cost of refusing is that the page is frozen in both directions until outl reconcile --ahead-of-log runs: those lines never reach another device, and a peer’s edits never reach this .md.
This table is generated from outl_actions::refusal (RFC 0255), the single owner of “which surface tells the user about which refusal” — the same mechanism outl_shortcuts::support uses for chords.
MCP is a fifth surface here, deliberately not part of outl_shortcuts::Client (three members: TUI, desktop, mobile — see outl-shortcuts/CLAUDE.md).
A refusal is owed to whoever asked for the write, whether or not that asker draws an outline.
PageMarkdownAheadOfLog
| Client | Surface |
|---|---|
| CLI | Every write subcommand that touches an existing page (page update, block append, template apply/run, …) returns the same structured PAGE_MARKDOWN_AHEAD_OF_LOG JSON error the MCP does, with --json. outl doctor names the page, the line count and one sample outside any write attempt. outl serve’s tree → .md sweep discovers it with nobody asking: it names the page and the recovery on the first sweep that sees it and on every change to the set, never once per 30s tick, and says so once more when the set clears. outl reconcile --ahead-of-log is the recovery on all three. |
| Desktop | <PageAheadOfLogBanner client="desktop" /> above the outline, from PageView.md_ahead_of_log. Names the command to run in the workspace folder. |
| Mobile | Same banner, client="mobile". There is no outl binary on iOS, so the copy says to open the workspace on a computer instead of pointing at a terminal that doesn’t exist. |
| TUI | A status-line message wherever a TUI-initiated write re-projects the page (template apply, call: code-block exec, mention-creation autocomplete) and a toast when a peer-sync reload’s re-projection declines (SyncEngine::reproject_page, reload_workspace_from_disk). The TUI still does not call apply_page_md_with_sidecar_if_stale on its own page-open path, so a page that drifted ahead of the log with no local write attempt in between stays silent until the next write touches it. |
| MCP | A structured tool refusal — PAGE_MARKDOWN_AHEAD_OF_LOG — naming the page, line count, sample, and the recovery command, instead of a generic failure. |
Regenerate with OUTL_UPDATE_CLIENTS_DOC=1 cargo test -p outl-actions refusal::, pinned by outl_actions::refusal::tests::the_clients_doc_matches_the_refusal_matrix — never hand-edit the region between the markers.
The user-facing wording on the GUI clients is owned by @outl/shared/warnings::aheadOfLogNotice (unit-tested), never written inline in a client.
The MCP’s wording has no shared owner across the Rust/TypeScript boundary — see the module doc on crates/outl-cli/src/mcp/tools/dispatch.rs for why, and what it does instead: it forwards ActionError::PageMarkdownAheadOfLog’s own Display verbatim rather than writing a second sentence.
Both banners also warn against editing the page in the meantime: a local edit is safe: ProjectionWriter routes through apply_page_md_with_sidecar_guarded, which refuses to project over unlogged content exactly as the open path does. The .md simply stays behind until the lines are recorded.
A refusal discovered after the mutation is persisted is not returned as a command failure: synchronous paths attach it to the successful PageView, while ProjectionWriter emits projection-write-failed with the same structured notice.
Desktop and mobile feed either route into the existing sticky banner; other projection failures retain their original message and use the existing status/toast surface.
The page still opens and still shows what is on disk — the guard withheld a write, not the page. Before this banner existed the refusal only reached a backend log line, so the page appeared to freeze with nothing said.
When the banner clears.
Only the open commands run the check, so a mutation reply can never carry the notice; a client that read it off every reply would clear the banner on the user’s first edit, which is the action the banner warns against.
PageView.md_ahead_of_log_checked marks the replies that did run it, and those are authoritative in both directions: the first checked reply with no notice (the next open or refresh of the page, after outl reconcile --ahead-of-log fixed it) clears the banner on both clients.
A banner that outlives the condition is the mirror of the silence this section exists to end.
Page history
The op log holds every revision of every block. outl_actions::timeline is the single owner of turning that into “what happened to this page” — which blocks count as the page’s, which ops are not events, and the order. No client re-derives any of it.
Read-only on every surface. Restoring a revision is outl recover, which covers one narrow case with a provably-additive rule (the recovered text contains the current text as a prefix). A general “put this back” button needs its own safety argument and does not exist.
Two rules that decide what a reader sees:
- A deleted block stays in its page’s history, with the text it held when it went. A history that omits deletions answers “what changed” with everything except the change people open a history to find. A block moved to a different page goes with it;
block_timelinefollows one block wherever it has lived. - Not every op is an event. Folds, snoozes,
page-slug/page-kindwrites, anOp::Editthat re-emitted the block’s existing text, and a re-emittedCreate/Movethat changed nothing are all skipped. A reconcile produces them in volume — on a real 64k-block workspace one block’s history was six such rows around a single real edit.
| Client | Surface |
|---|---|
| CLI | outl page history <slug> and outl block history <id>, both with --limit and --json. See docs/cli.md. |
| Desktop | The ⏱ button in the page-header eyebrow opens <TimelinePanel />; Esc closes. No chord yet. |
| Mobile | Not yet. The command body is in outl-tauri-shared, so it is a handler registration plus a surface. |
| TUI | Not yet. |
Related but different: undo / redo (outl_actions::history) is this session’s mutations, and outl backup is workspace-granular git snapshots. Neither answers “what did this page say last Tuesday” — that is this.
Restoring a deleted block
Delete is Move(node, TRASH_ROOT) (invariant 6), so nothing is ever physically removed. outl_actions::trash is the single owner of reading that back — what is in the trash, where each entry came from, and whether putting it back would work.
Deliberately narrower than it looks. Three things it does not do, each for its own reason:
- A page cannot be restored. It needs a re-projected
.mdon top of theMove, and on a real 2,576-page workspace 16 of the 18 deleted pages have their slug taken by a live page today. Picking a free slug would make this a second owner of the slug rule, so it refuses and names where the content still is (outl block tree <id>, sinceoutl page historyonly resolves live slugs). - A block whose parent is also in the trash cannot be restored on its own — 89 of 393 top-level deletions on that workspace. Restoring it would succeed structurally and change nothing the user can see; the refusal names the ancestor to restore first.
- A restored block lands as the last child, not in the slot it held.
Move.old_positionis the originating replica’s local derivation forundo_op, not something a reader of the log may trust.
The listing and the mutation share one verdict function (trash::refusal_for). A listing that decided “restorable” for itself would drift towards offering an action that then fails — the same rule invariant 8 applies to which pages are safe to overwrite.
| Client | Surface |
|---|---|
| CLI | outl trash list and outl trash restore <id>. See docs/cli.md. |
| MCP | outl_trash_list, outl_trash_restore — an agent that can delete a block can undo it. |
| Desktop | Not yet. |
| Mobile | Not yet. |
| TUI | Not yet. |
No client surface exists, which is why Capability::Trash is in outl_shortcuts::CLI_ONLY rather than simply missing: a capability nobody reaches is normally a feature that does not exist, and that exemption has to be a declared row with a reason instead of a weakened test. Tracked in issue #287.
Related but different: undo / redo is this session’s mutations and does not survive a restart; this reaches a deletion from any device, on any day, because it reads the op log.
Running code blocks
Every client that lets the user execute a ```lang ``` block (TUI g x, desktop Cmd+Shift+X / Run button, mobile long-press → “Run code”) goes through one shared entry point:
outl_actions::exec::run_code_block(ws, hlc, root, registry, page, block).
client gesture (TUI chord / Cmd+Shift+X / long-press)
│
▼
outl_actions::exec::run_code_block
├── outl_actions::flat_index_for_block (DFS-locate the block)
├── outl_actions::journal::page_md_path (resolve .md path)
└── outl_exec::run_block_at_index (execute + persist > **result:** sibling)
│
▼
RunCodeBlockOutcome { language, result_ok | error }
│
▼
client wraps with refreshed PageView and ships it down its Tauri/TUI surface
The DTO returned is intentionally narrow — language, result_ok (stdout/stderr/duration/exit), error.
Clients add the refreshed page projection themselves because each client owns its own PageView shape (mobile’s iCloud-backed variant differs from desktop’s path-picker variant).
The duplication that used to live in outl-desktop/src-tauri/src/commands/exec.rs and outl-mobile/src-tauri/src/exec.rs was collapsed into this single function.
flat_index_for_block and the path lookup were the canonical “two parallel implementations” case the workspace-level Reuse-first policy exists to prevent.
The runtime catalog is selected per-binary via outl-exec features:
outl-cli,outl-tui,outl-desktop: default features (JS + Python + Lua + Rust via wasmtime).outl-mobile— opts out oflang-rust(wasmtime is heavy and trips iOS code-signing restrictions on dynamic code generation).lang-lisp(Steel) is in no default set: Steel exposes no allowlist to build a host boundary from, so alispfence can reach a shell however carefully the runtime shadows names. A build that only runs the user’s own code can turn it on; see the comment incrates/outl-exec/Cargo.toml.outl-actions—default-features = falseso it never dragswasmtimeinto the mobile IPA via the back door.
Structural templates
A template is any page with a non-empty template:: property; its outline is a body every client can deep-copy under a target block (see docs/templates.md for the authoring model).
Instantiation is reachable from every surface (TUI/CLI /template <name>, MCP), and both GUI clients wrap the same two shared command bodies in outl_tauri_shared::commands::template — no plugin needed:
list_templates→Vec<TemplateDto>({ name, slug, duplicate }) — wrapsoutl_actions::list_templates.instantiate_template_at(name, target_block)→ refreshedPageView. It resolves the target block’s enclosing page (slug + journal date), callsoutl_actions::instantiate_template, reprojects, and announces the ops. An unknown name or stale block id is a typed error the client toasts.
Both clients register the wire command as list_templates_cmd plus instantiate_template_at.
The _cmd suffix dodges a glob-import collision with the outl_actions::list_templates re-export.
The TS wrappers are listTemplates() / instantiateTemplateAt(name, targetBlockId) in @outl/shared/api/commands.
Client affordances (chrome per-client, one backend):
- Desktop — the block-initial
/slash menu liststemplate: <name>entries alongside plugin commands. They are injected viatemplateSlashCommandsunder a reserved@outl/templatesentinelplugin_id. Picking one instantiates under the selected block;OutlineView’sonRunPluginCommandintercepts the sentinel and callsinstantiateTemplateAtinstead ofpluginRun. - Mobile — the block long-press menu has an “Insert template” action that opens
TemplateSheet(a bottom sheet listing templates). Picking a template instantiates it under the long-pressed block and applies the returnedPageView.
Copy and paste
Why copy-out and paste-in are one pair, and why the core speaks exactly one format: RFC 0044.
Every client supports copying blocks as clean outl markdown and pasting markdown from external apps.
Copy out
| Client | How to copy | What is copied |
|---|---|---|
| TUI | yy / Y (Normal) or y (Visual range) | Selected block(s) + full subtrees as canonical outl markdown, written to the OS clipboard via arboard (X11/Wayland/macOS) with an OSC 52 fallback for SSH / tmux / Crostini. Status line confirms: yanked N block(s) → clipboard or (clipboard unavailable). |
| Desktop | Y (Normal) or y (Visual range) | Same serialisation via the copy_markdown Tauri command (outl_actions::copy_markdown) + navigator.clipboard.writeText. |
| Mobile | Long-press → “Copy” in the context menu | Block + full subtree via the copy_markdown Tauri command, written to the iOS clipboard. |
The serialisation is handled by outl_actions::copy_markdown on the Rust side and copyMarkdown (@outl/shared/api/commands) on the TS side.
The format is canonical outl markdown: - bullets, 2-space indent, inline block props alpha-sorted, TODO/DONE/> prefixes verbatim.
Pasting the result back into any outl client reconstructs the same tree.
Paste in
Every client distinguishes paste with formatting from paste without formatting.
With formatting (Cmd/Ctrl+V on desktop, p in the TUI, the default paste on mobile) routes the clipboard through outl_actions::paste_markdown when the content looks structured.
It applies these conversions:
- Roam
{{[[TODO]]}}/{{[[DONE]]}}→TODO/DONEprefix. - GitHub
- [ ]/- [x]→TODO/DONEprefix. - Logseq
id::metadata → stripped. - 4-space indent → 2-space indent.
- Multi-line plain text (two or more non-blank lines) → one block per non-blank line (blank lines are ignored).
- Single-paragraph plain text → falls through to the browser/terminal default splice.
Without formatting (Cmd/Ctrl+Shift+V on desktop, P in the TUI; not available on mobile) calls outl_actions::paste_plain.
The raw clipboard text is inserted as a single block at the anchor with no normalisation, outline parsing, or paragraph splitting.
Use this when the text contains underscores, brackets, or other characters that would be misread as markdown syntax.
The routing decision uses two helpers from @outl/shared/paste:
looksLikeOutline detects bullet structure;
hasMultipleParagraphs is true when the text has two or more non-blank lines (blank lines are ignored).
Either condition sends the paste to the backend (structured path) on Cmd/Ctrl+V.
| Client | With formatting | Without formatting |
|---|---|---|
| TUI | p — routes to backend when outline or multi-paragraph; else native splice | P — raw text, single block |
| Desktop | Cmd/Ctrl+V — routes to backend when outline or multi-paragraph; else native splice | Cmd/Ctrl+Shift+V — raw text, single block |
| Mobile | paste — routes to backend when outline or multi-paragraph; else native splice | not available |
TUI mouse drag copy (opt-in)
With [tui] mouse_capture = true in ~/.config/outl/config.toml, dragging across blocks in the TUI selects a range and copies it as outl markdown on release.
See tui.md → Mouse capture and config.md → [tui] for details.
Attach / drag-and-drop file import
Every client can bring an outside file (PDF, image) into a workspace as an asset link, without leaving the outline.
All three routes land on the same backend copy: content-addressed into <root>/assets/<hash>.<ext>, idempotent, size-capped by [assets] max_bytes.
See markdown-format.md → Asset links for the link format and the OS-open behavior.
| Client | File picker | Drag-and-drop |
|---|---|---|
| Desktop | 📎 Attach file button or /upload slash command → OS file dialog → new block at page end | Drop a file on a row → link lands in the drop-on block (else selection, else the block being edited via caret splice) |
| Mobile | Block long-press → “Attach file” → new block after it | iPad only: drop a file on a row, same target resolution as desktop; degrades to the long-press action on iPhone |
| TUI | /upload slash command (alias /attach) → native OS dialog on macOS/Windows, typed path (upload <path>) on Linux/SSH → link at the cursor | Insert mode: paste a payload that is exactly an existing file’s path (how terminals report a dropped file) → link spliced at the caret |
Desktop and mobile share the drop-handling code (installFileDrop + importAssetFile, @outl/shared/drag-drop), so the drop-target resolution can’t drift between the two GUI clients.
See outl-frontend-shared/CLAUDE.md for the pure helpers underneath.
The TUI has no OS drag events over SSH/tmux, so it treats a lone pasted file path as a drop instead (outl-tui/CLAUDE.md → “Drag-and-drop file upload”).
Opening a file from the OS (“Open With → outl”)
A file manager can hand outl a .md / .markdown / .txt / .text file that lives outside the workspace.
outl is not a text editor and never writes back to that file: the gesture means import a copy of this into my outline, so what lands is an ordinary page whose content arrived through the op log like every other page.
Where it lands: a page titled open-in/<file name>.
The slash is a title namespace, not a slug — page::is_valid_slug rejects / because a slug is one path component — so the file projects to pages/open-in-<name>.md and open-in becomes a real parent page listing everything ever opened this way (Nested pages).
Re-opening the same file navigates; it does not import again.
The page records where it came from in a page-source:: property, which is internal and never rendered into the .md: its value is a local absolute path, a .md is often in git, and outl export hugo copies every property outside its deny-list into published front matter.
Importing twice into one page would duplicate every block, and overwriting would delete whatever the user wrote on the page after the import — so the second open returns the page the first one produced.
That property is also what keeps two different files with the same name apart: ~/a/notes.md and ~/b/notes.md both want open-in/notes, and the second gets open-in/notes 2 rather than being silently merged into the first.
The content split follows the paste pipeline, not a second rule: a bulleted .md lands as an outline, a bullet-free payload lands as one block per non-blank line (see Copy and paste).
The imported page is linked from today’s journal.
A [[open-in/<name>]] block lands in the journal for the day the import happened, so the page has a trail: outl is journal-first, and a page reachable only by search is a page you forget you have.
It is a ref, not plain text, so the page itself lists that entry under its backlinks.
The ref names whichever of title or slug actually resolves: a ref is matched by slugified name before an exact title, so a page whose title no longer derives its slug (the non-ASCII fallback above) is linked by slug, or the link would open the open-in index instead.
Re-opening a file imports nothing and therefore writes no second entry, however many times you open it.
| Client | How a file reaches it |
|---|---|
| Desktop | fileAssociations in tauri.conf.json puts outl in the OS “Open With” list (rank: Alternate, so it never steals another app’s default). macOS delivers an Apple Event (RunEvent::Opened), Linux / Windows deliver argv. Both buffer until the frontend announces it is listening, then emit open-file://import — “the process is running” is not the same as “the webview has mounted”, and a slow workspace boot is exactly when a user opens a file. Both routes end at the open_external_file command |
| Mobile | The command is registered and nothing hands it a file — no share-sheet / document-type association is declared yet, so iOS and Android never offer outl in “Open in”. Recorded as Capability::OpenExternalFile = Missing |
| TUI | Not applicable — a file manager has no terminal process to hand a file to |
Policy (namespace, the source:: rule, the extension / size / UTF-8 refusals) lives in outl_actions::open_with and nowhere else; each client only turns an OS gesture into one path string.
Task state convention
A block’s task state is a prefix on its text, not a property:
"foo" plain block
"TODO foo" open task
"DOING foo" started task
"DONE foo" completed task
This is the wire format the TUI already uses and what .md files contain when synced to other tools.
outl-actions::cycle_todo walks None → TODO → DOING → DONE → None; UI surfaces parse the prefix out via split_todo to render a checkbox.
Never re-derive the cycle order — markdown-format.md owns it.
Blockquote convention
Blockquotes follow the same shape as TODO/DONE — a per-block text prefix, no AST field, every client renders its own visual.
The prefix is the CommonMark "> " (greater-than + single space), so an .md round-trips cleanly when an external tool opens it:
"foo" plain block
"> foo" quoted block
outl-actions::quote::toggle_quote flips the prefix on/off; split_quote separates the marker from the body for UI rendering.
Composition with TODO/DONE follows a canonical order: "TODO > body" (task state before quote marker), so the backend’s split_todo still surfaces block.todo in the DTO when the block is also quoted.
Both toggle_quote and cycle_todo peel both prefixes off and re-emit in canonical order — an externally authored "> TODO foo" gets normalised to "TODO > foo" on the first toggle from a client.
Multi-line quote bodies keep the > on every continuation line so the .md stays a valid CommonMark blockquote.
Children of a quoted block are not implicitly quoted — the marker lives on the block, not on its subtree.
GUI clients render the outline bullet outside the quote chrome, so the quote is visually the body’s content rather than a nested list item.
Inline tokens (**bold**, [[ref]], #tag, ((blk-…))) continue to tokenize inside the body — the wrapper is transparent.
Reminders (remind::)
A block can carry a notification rule as a property — remind:: 3pm every 1h until DONE.
The user-facing spec is reminders.md; this section is the client contract.
One owner for the schedule.
outl_actions::reminders::next_fire_at(rule, anchor_date, state, quiet, now) decides when a rule fires.
It is pure and clock-free — now is a parameter — and every surface calls it: the TUI overlay, the desktop panel, the mobile sheet, and each OS notification bridge.
Never re-derive a schedule in TypeScript or Swift.
A second opinion about “in 3 hours” is drift that reaches the user at 3am, on one device, before it reaches a test.
The scan reads the tree, not the .md.
scan_reminders finds carriers through Tree::nodes_with_property(REMIND_KEY).
Reading the projection instead would have been stale by construction: the GUI clients write .md asynchronously (see Async projection writes), so a rule authored a moment ago isn’t on disk, and the user who just pressed “remind me” would open the list to find nothing.
What converges and what doesn’t.
| state | converges? | lives in |
|---|---|---|
the rule, the block’s [[date]], TODO/DONE | ✅ | op log |
| snooze | ✅ | Op::SnoozeRemind |
| ”this device already fired it” | ❌ | <root>/.outl/reminders-fired.json — a dotfile, so iCloud drops it and iroh never ships it |
| quiet hours, the enabled flag | ❌ | ~/.config/outl/config.toml |
Snoozing on the phone must silence the laptop; the phone having buzzed must not stop the laptop from buzzing. That split is the whole design.
Delivery is per-client, not shared.
outl_tauri_shared::reminder_runtime::take_due answers what came due and updates the device-local fired log; each client wraps it with its own OS call (tauri-plugin-notification on both GUI clients today).
A 30s poll drives it — the schedule has minute granularity, so a tighter tick only adds IPC, and the fired log makes a double poll harmless.
Every client delivers, including the TUI.
That is why take_due and the fired log live in outl-actions and not behind the Tauri layer: the TUI can’t depend on outl-tauri-shared, and putting them there would have made “the client that shipped first” the owner.
The GUI clients wrap take_due with tauri-plugin-notification; the TUI wraps it with an OSC 9 escape plus a toast on its event-loop tick.
A terminal session still has no background presence, so a reminder due with the TUI closed is lost to that client.
Presentation is shared.
@outl/shared’s formatNextFire (“in 3h”, “tomorrow 09:00”) and groupReminders (Today / Tomorrow / This week / Later / Done) are used by both GUI clients — two implementations drift on the edge cases (exactly 60 minutes, midnight rollover) long before anyone notices.
App-closed delivery is not covered yet. See reminders.md → Background delivery for what each OS still needs.
Zoom / focus on a block (Roam/Workflowy)
Zooming makes one block the outline root so only its subtree renders — the Roam/Workflowy “focus” gesture.
It is local view state, never an Op and never a Tauri round-trip: the client already holds the whole outline, so zoom is a pure decision about what to render, and it does not converge across devices (each device focuses independently).
On the GUI clients (desktop + mobile) the subtree lookup + ancestor breadcrumb is the shared focusSubtree(blocks, blockId) in @outl/shared/outline, returning { root, breadcrumb } or null when the id is gone.
This is pure view state with no Rust mirror — the id never leaves the frontend; the TUI reaches the same behaviour through its own path-based zoom stack in outl-tui/src/actions/zoom.rs.
Every client resolves the focused id against the live outline on each render, so an edit / collapse inside the zoom stays reflected, and a stale target (block deleted or moved to another page → null) transparently falls back to the full page.
Switching pages drops the zoom, so focus is scoped to the page it was set on.
On mobile the gesture is touch, not a chord: a tap on a block’s plain bullet dot zooms in (Journal.tsx holds one focusBlockId signal; BlockRow → BulletOrCheckbox fires onFocusBlock when wired).
That repurposes the plain dot’s old mark-as-TODO tap — TODO stays reachable in the long-press context menu — while the TODO/DONE checkbox and the collapse triangle keep their taps, so no gesture collides.
Zoom-out is stackless: a ← Back header button plus a tappable ancestor breadcrumb, derived by re-resolving focusSubtree and stepping to breadcrumb.at(-1) (the parent) or exiting when already top-level.
Keyboard accessory bar (mobile)
The edit toolbar docked above the soft keyboard — a Bear-style pill of + / indent / bold / [[ / TODO / delete / hide-keyboard — plus the ref/emoji chip strip, exist in two renderings.
iOS is native: OutlToolbarView supplied as the private WKContentView’s inputAccessoryView via the OutlSwizzle swizzle, suggester in OutlSuggestOverlay / OutlSuggestView.
Android (and iOS later) is web: KeyboardAccessory.tsx renders <SuggesterStrip /> + <KeyboardToolbar /> in the webview, bottom-anchored at useKeyboardInset(), gated on isAndroid && editingId() !== null.
There is no web equivalent to iOS’s docked accessory view, so the native bar stays until the web one is proven on a device.
The logic is shared, never triplicated.
The action catalog + most-frequently-used ordering live in @outl/shared/toolbar, a port of crates/outl-mobile/swift/OutlKit/Toolbar/{ToolbarAction,ToolbarMFU}.swift.
The action string ids (newLine, indent, todo, …) are the wire contract the iOS native bar ships to JS via window.__outlToolbar(action).
So the Swift ToolbarAction copy and the TS catalog stay byte-identical — rename on both sides in one commit — until the native bar retires.
Both bars dispatch through the same Journal.tsx dispatchToolbarAction (the native bridge just assigns it to window.__outlToolbar).
Both accept a chip through the same window.__outlSuggesterPicked callback.
The web strip reads the reactive nativeSuggesterState signal that setNativeSuggesterState feeds alongside the window.__outlSuggesterState global the native strip polls.
Two invariants the web bar must keep.
Every button calls e.preventDefault() on onPointerDown so the tap can’t blur the textarea and dismiss the keyboard.
AndroidManifest.xml’s activity sets android:windowSoftInputMode="adjustResize".
With the interactive-widget=resizes-content meta the visual viewport then shrinks on keyboard show and useKeyboardInset() collapses to ~0, so bottom: inset rests the bar on the keys; drop adjustResize and it floats behind the keyboard.
Validation needs an Android device/emulator build — docking and visualViewport don’t exercise under host cargo check or bun run dev.
Backlinks order (issue #142)
Every client sorts the “Linked from” list the same way: outl_actions::sort_backlinks groups backlinks by source page (each page’s blocks stay contiguous, in document order) and orders the pages by how recently each was referenced.
The direction — newest (default, most recently referenced page on top) or oldest — is a pure display preference stored in [display] backlinks_order (config.toml), same non-converging policy as theme.preset.
| Client | Toggle | Persistence |
|---|---|---|
| TUI | Ctrl+O (Normal mode) | writes config.toml directly via outl_config::save |
| Desktop | direction button in the InlineBacklinks header | set_backlinks_order Tauri command |
| Mobile | direction button in the BacklinksSection header | set_backlinks_order Tauri command |
See config.md → [display] for the schema and shortcuts.md for the TUI chord.
Backlink breadcrumbs
When a citing block sits nested inside an outline (not at the page’s root level), the “Linked from” panel shows the chain of parent blocks as a dimmed breadcrumb directly above the citing block, root-first — e.g. Planejamento Q3 › Objetivos.
A block at root level has no breadcrumb.
Consecutive references from the same branch collapse: the trail renders once, and the following reference(s) sit under it silently until the branch changes.
outl_actions::backlinks_for_page / backlinks_for_target populate Backlink::ancestors (a Vec<BacklinkCrumb>) while walking the page tree, so every client reads the same data — nothing is recomputed client-side.
@outl/shared/outline::sameCrumbTrail (mirrored locally by the TUI as same_trail) is the one comparison every client uses to decide whether to collapse consecutive breadcrumbs, comparing trails by block id.
Mobile’s BacklinksSection groups references by source page and renders the breadcrumb the same way desktop and TUI do — it no longer shows a flat list of blocks.
Nested pages (issue #275)
A page whose title carries a / — os/linux, programming-language/rust — is nested under the namespace to its left.
Two things follow from that, and they are separate features that happen to share a source:
- The parent lists its descendants. Every client renders a “Nested pages” section below the backlinks, showing every page under the open one (all levels, not just direct children), title-sorted and indented by depth.
- The parent collects its descendants’ mentions.
A block tagged
#os/linuxis a backlink ofosas well as ofos/linux, through the backlink index’sTargetKey::Namespacechannel. Exact-tag matching is unchanged:#osand#os/linuxare still different tags.
The hierarchy is derived from the page title, never from the slug, and outl_actions::namespace is its single owner.
A slug is one filesystem path component (pages/os-linux.md), so slugify folds / to -; the / the user typed survives only in title::.
Comparison is per slugified segment, so OS/Linux and os/linux are one namespace and oscar/wilde is not under os.
descendants hands each client rows that already carry depth and label, so nothing splits a title on / client-side — the desktop and mobile share one <NestedPages /> from @outl/shared/namespace.
| Client | Section | Open a row |
|---|---|---|
| TUI | view/namespace.rs, below the backlinks | No — j/k stop at the backlinks section; open one with the picker (Ctrl+P) |
| Desktop | <NestedPages /> in OutlineView | Click |
| Mobile | <NestedPages /> in Journal | Tap |
The TUI gap is recorded as Capability::NestedPages → Partial in outl_shortcuts::capability_support (see client-parity.md), and the section header carries the nudge so the user is not left wondering whether the keys are broken.
The rows ride the page_backlinks reply (PageBacklinks.namespace_children) rather than a command of their own: they answer the same question at the same moment, off the page list that call already read.
Backlinks index (performance)
One definition of a mention, one index, four clients: RFC 0169.
outl_actions::backlinks_for_page walks the whole workspace (O(blocks)) on every call.
Recomputing that on each page open made opening a page — or today’s journal — noticeably slower as a workspace grew.
Every client now keeps a pre-computed inverted index (outl_actions::BacklinkIndex) instead of scanning per open.
Once built, a page’s backlinks are an O(refs-of-the-page) lookup.
The index is built from the .md files on disk (outl_actions::build_backlink_index_from_disk), not from the in-memory Workspace.
An earlier version built it from the workspace (Workspace::block_text on every block).
That forced a lazy-boot vault (#179) to materialize entirely and held the workspace lock across the whole O(blocks) walk — the “opening the journal / pressing Esc freezes” regression.
The .md projection already carries every block’s text, properties, and sidecar id, so the from-disk build needs no lock and no Yrs materialization.
| Client | Where the index lives | Rebuild |
|---|---|---|
| TUI | App::backlink_index (RefCell<Option<BacklinkIndex>>) | Inline, on the next dirty read (ensure_backlink_index), from disk. The TUI owns its Workspace single-threaded, so it can’t hand the build to a worker the way the GUIs do — the from-disk walk stays fast enough (disk I/O + parse, no lock, no materialization) to run inline. |
| Desktop / Mobile | AppState::backlink_index (Arc<Mutex<Option<BacklinkIndex>>>), exposed through AppHost::backlink_index() | Off the IPC thread, inside the page_backlinks command (spawn_blocking, compute_backlinks_offloaded): a brief workspace lock reads the page list + this page’s meta, the index rebuild itself runs from disk with no lock held, then a brief lock does the O(refs) lookup. |
A regression test (outl-actions/tests/backlinks_index_disk.rs) asserts the from-disk build never materializes the workspace, via Workspace::resident_text_count() (the same counter #179’s fix introduced, now pub for this exact check).
The index is a dirty-flag cache, never a sync surface — it is dropped (set to None) on every event that can change what a page’s backlinks are, and rebuilt lazily on the next read:
- a local mutation (
App::invalidate_backlinks_cacheon the TUI,finish_in_page*on desktop/mobile), - a peer or workspace reload,
- a plugin run that applied ops.
The index stores each referencing block as a shallow leaf — body, tokens, ancestors, no subtree.
Every client renders a backlink row from source_block.tokens, and materializing the subtree of every referencing block across the workspace (under the workspace lock) is exactly what froze input, so the build stops at the leaf.
One visible consequence: a backlink shows only the citing block, not its child blocks, on every client (the TUI no longer expands a backlink into a mini-outline).
Async projection writes (performance)
Every client is async-on-write: the op log is written synchronously (Workspace::apply → Storage::append_op), but the .md + sidecar projection — the file the user actually sees on disk — happens after, off the input path.
No client blocks a keystroke or a Tauri command reply on a .md render, an fsync, or a backlink rebuild.
| Client | Op log write | Projection write |
|---|---|---|
| TUI | Synchronous, on commit (Esc, Enter, structural ops) | Deferred. The commit marks the page dirty and repaints immediately; the render → .md write → reconcile_md drains the moment the event loop goes idle, or after 600ms mid-burst (runtime::MAX_SAVE_DEFER). Always flushed before quit, Ctrl+S, navigation, peer reload, or a call: re-run — see docs/tui.md → Behavior worth knowing. |
| Desktop / Mobile | Synchronous, inside the Tauri command | Queued to a background ProjectionWriter thread (outl_tauri_shared::ProjectionWriter::queue), which coalesces bursts and writes .md + sidecar under the workspace lock. The command’s reply is built straight from the in-memory tree (build_page_view_from_tree), not from a re-read of the file it just queued. |
A crash between the op-log write and a queued projection leaves the .md briefly behind the op log — that’s not data loss.
The op log is authoritative: the next boot re-projects any stale page, and peers sync ops, never .md files.
When re-projection is withheld
“The next boot re-projects any stale page” has three exceptions, and they are user-visible: the page keeps showing what is on disk instead of what the tree holds.
outl_actions::apply_page_md_with_sidecar_if_stale — the call every GUI open path makes — declines to write when:
- the
.mdno longer matches its sidecar (an external edit is pending;outl reconcileowns it); - the
.mdholds content that exists in no op, which surfaces as an error naming the line count and one sample (outl doctorlists the pages,outl reconcile --ahead-of-logbrings them into the log); - the sidecar cannot answer whether it does — every block written without
SidecarBlock::text, i.e. by a peer still on a pre-0.11 binary. That page is already queued for the pipeline migration, whose reconcile rewrites the sidecar with text; the next open re-projects normally.
The same rule governs the desync recovery (recover_desynced_projection): it recovers the lost ops but leaves the file alone when a block the log already knows carries different text on disk.
A stale view is recoverable; deleted bytes are not.
Reasoning and the incident behind it: RFC 0210, root CLAUDE.md invariant 8.
Plugin onOp hooks on desktop/mobile are fire-and-forget (no await on the reply path) for the same reason — a slow plugin can’t stall the next keystroke or command.
Automatic backups
outl_actions::backup is the shared engine: local, versioned git snapshots of a workspace, with outl backup list|restore on top.
[backup] enabled defaults to on (config.md → [backup]), so the pass that makes that default true has to exist on the client, not just in the CLI.
One call wires a client up: outl_actions::backup::spawn_auto_pass(root, enabled, interval_minutes).
It spawns a detached background thread, waits STARTUP_DELAY (60 s — boot is already the busiest moment a client has), then snapshots whenever interval_minutes has passed since the newest commit.
| Client | Automatic snapshots | Where it’s wired |
|---|---|---|
| TUI | Yes | runtime::run_with_theme_override, right after clock::init |
| CLI subcommands | No — a one-shot process; outl backup now is the explicit path | — |
| Desktop | Not yet | — |
| Mobile | Not yet — there is no git binary on iOS | — |
Three rules any client that wires this up must keep:
- Never on the edit path.
A snapshot walks the whole workspace and forks
git; on a large graph that is seconds. It belongs on a background thread, not on a tick, a commit boundary, or an idle hook that the render loop waits on. - Never on the quit path.
Blocking an exit on a whole-workspace
git addis a worse bug than a snapshot arriving one session late — and the interval floor is derived from the git history, so the next launch takes it. - Never fatal.
No
gitonPATH, no disk, a refused commit — all of it degrades to awarn!andNone.
The repository is device-local and outside the workspace (~/.config/outl/backups/<name>-<hash>.git, workspace as --work-tree).
That is what makes it safe for the pass to create the repository on first run: it can’t turn the user’s notes folder into a git repo, it can’t collide with a repo they already keep there, and no file transport replicates it between devices.
iCloud sync (mobile + TUI, today)
The iOS app is on a public TestFlight beta — https://testflight.apple.com/join/P2GdWAMd. Install it on the iPhone, then point the TUI at the same iCloud Drive container to share the workspace.
The mobile client persists the op log to the iCloud Ubiquity Container.
The TUI reaches the same workspace by pointing --workspace at the container’s Documents/ directory:
<container>/Documents/ ← TUI: outl --workspace "<container>/Documents"
├── journals/
│ └── YYYY-MM-DD.md ← daily journal projection
├── pages/
│ ├── <slug>.md ← regular page projection
│ └── <slug>.outl ← sidecar (block IDs + hashes)
└── ops/
├── ops-<this_device>.jsonl ← only this device writes here
├── ops-<other_device>.jsonl
└── ...
The folder is
ops/, not.ops/. iCloud Documents / Ubiquity Containers do not sync paths starting with.across devices, so a dotted name silently breaks multi-device sync. The same rule is why the sidecar moved from.foo.outltofoo.outlin v0.
Each device only writes to its own ops-<actor>.jsonl, so iCloud never has to merge file contents — the CRDT does that work after reading every actor’s ops.
The .md projection is rewritten after every mutation — the TUI coalesces the write to its next idle/flush point, desktop/mobile queue it to a background writer (see “Async projection writes” above) — so it can briefly lag the op log by a beat.
Do not parse it back to reconstruct workspace state; the op log is authoritative.
Shared sync engine
Both clients use outl_actions::SyncEngine for the reload-workspace + reproject-page flow.
Detection is client-specific (TUI runs a worker thread polling snapshot_peers() every ~2s; mobile registers NSMetadataQuery on the ubiquity container).
Once detection fires, the call site is identical:
let engine = SyncEngine::new(workspace_root, actor);
let fresh = engine.reload_workspace(&hlc)?;
engine.reproject_page(&fresh, focused_page_id)?;
hlc is the client’s live generator, and passing it is not optional plumbing: the reload raises it past every op it just merged.
See docs/sync.md → The shared sync engine for why.
The TUI defers the reload while the user is in Insert mode (the in-flight ParsedPage would be clobbered) via a pending_reload flag drained on commit.
Mobile applies immediately because every mutation is one atomic Tauri command.
The policy diverges; the engine does not.
engine.scan_for_orphans() is the other shared piece: it walks journals/ and pages/ for .md files whose sidecar is missing or stale (fresh import from Roam/Logseq, peer-shipped projection without sidecar, external vim edit).
The TUI runs the scan every 10s on a worker thread; mobile runs it once at boot.
Both feed the same outl_md::reconcile::reconcile_md.
Split-brain slug repair (boot)
outl_actions::merge_duplicate_slug_roots(ws, hlc) is the boot-time repair for the split-brain bug where two creators minted different ids for the same slug.
Two 2026-07-10 journal roots split a day’s content across two roots, so the client flickers between them.
For each slug with more than one root it picks a canonical survivor: the page_id_from_slug id if one is present, else the root with the most descendants, tie-broken by smallest id.
It then re-parents every child of the other roots under the survivor preserving order (no data loss), and trashes the emptied duplicates.
Every step is an Op through Workspace::apply, so running it on any client converges on every device via the CRDT; it’s idempotent (returns 0 on a clean workspace) and safe to call on every boot.
Clients call it once at startup alongside migrate_legacy_into_today.
Belt-and-suspenders: find_by_slug already resolves the same canonical winner deterministically, so the UI stops flickering even before the merge runs.
Doubled journal title repair (background)
Page and journal roots get a deterministic id (page_id_from_slug), so two devices that create the same slug offline mint the same root and its Op::Create converges cleanly.
Before the fix, each device also wrote the title straight into the root’s Yrs text.
Those two concurrent inserts at position 0 concatenated instead of converging — a 2026-06-25 journal opened offline on two devices ended up titled 2026-06-252026-06-25.
open_or_create now writes the title into a title:: property instead (Op::SetProp, last-write-wins by HLC), and only when the title differs from the slug.
A journal’s title always equals its slug, so journals carry no title:: property and no title:: line lands in their .md.
Regular pages created in-app now render title:: <title> at the top of their .md.
outl_actions::repair_doubled_journal_titles(ws, hlc) cleans up journals corrupted before the fix.
Any journal root whose text is its slug repeated two or more times gets that text cleared — an Op, so the repair converges to every device — and the title falls back to the slug.
Idempotent, journal-only, and run on the background reconcile pass, not boot, since it scales with page count.
Desktop’s spawn_background_reconcile and mobile’s spawn_workspace_opener both call it.
Peer reachability indicator (P2P / iroh transport)
When the iroh transport is running, the desktop / mobile “online / offline” dot reads SyncTransport::peer_health() — a reachability snapshot the transport fills from its own dials (boot connect, catch-up loop, gossip-triggered sync).
The GUI must never stand up a second iroh endpoint to probe peers: a second endpoint sharing the device identity hijacks the relay route from the live sync endpoint, and inbound sync gets refused.
The outl_peer_status command merges the snapshot onto the full peers.json list, so a peer the transport hasn’t dialed yet shows offline.
The CLI’s outl peer status is the lone exception (no running transport), so it keeps the transient-endpoint probe.
See crates/outl-sync-iroh/CLAUDE.md → “One endpoint per identity, elected not assigned”.
Sync progress feed (pairing screen)
Desktop’s Sync panel and mobile’s Devices sheet both show a live feed while a sync pass runs against a paired device.
It reads a sync-progress Tauri event the iroh transport emits as the pass advances — connecting, pulling a snapshot, ingesting or pushing ops, done or failed.
This is purely cosmetic: a dropped update never breaks anything, because the actual “reload the workspace” trigger is a separate, load-bearing signal the progress feed never touches.
What each phase shows:
| Phase | What the user sees |
|---|---|
| Connecting | ”Connecting to <peer>…” |
| Snapshot | A real progress bar (%) — the total byte count comes from the frame’s length prefix, known before the body arrives. |
| Asset | A real progress bar (%) for each binary asset (PDF / image) transferring, in bytes of the current file. Assets are content-addressed blobs replicated outside the op log; over iroh they travel on the outl-asset/1 stream. |
| Received ops / Pushed ops | A live count (“Receiving 42 changes from <peer>”), never a bar — a batch’s size is only known once it finishes landing, so it can’t be turned into a percentage. |
| Synced / Failed | A one-line result for the pass. |
| Interrupted | Amber, "<peer> went away — will retry". Not in the feed. |
interrupted changes only the colour and the wording — the pass is still an error internally and the peer is still re-pushed.
It stays out of the activity feed (pill only, like connecting): an event that fires on every screen lock would bury the lines that matter.
Why it is a separate phase, and the mirror-image bug where a refused dial showed nothing at all: sync.md → Reading the Sync panel.
Below the pill, an activity feed lists “device → page” lines as ops land. This is best-effort: the transport ships only the (capped) block ids a batch touched, and the client resolves them to page/journal slugs with a Tauri round-trip. A block id that hasn’t materialized on this device yet (the reload may still be in flight) resolves to nothing, and the line just keeps its raw op count instead of a page name.
The per-page feed is empty during the initial pair. A freshly paired device pulls the whole log in one bulk pass, so naming every page it touched is meaningless — the feed only starts naming pages once ordinary incremental syncs (catch-up ticks, gossip-triggered pushes) run afterward.
Implementation: outl_actions::SyncProgress (crates/outl-actions/src/sync.rs) is the wire payload, threaded through SyncTransport::set_progress_sink and bridged to the sync-progress Tauri event by
outl-tauri-shared’s iroh_sync::start_with_reload_bridge.
@outl/shared/peers (createSyncProgress + <SyncProgressView />) is the one shared implementation both clients render, so the two panels can’t drift.
See crates/outl-mobile/CLAUDE.md for the full bundle ID, signing team, container ID set required to build it, and the NSFileCoordinator-based peer-file materialisation step that has to run before any read of a peer ops-*.jsonl.
Opening a page from a user-typed ref
When a click on [[avelino/outl]], #code-review, [[2026-06-04]]
or a picker field hands a client a string, the client must not split
the “journal vs page” decision between a frontend regex and a
backend parser.
The two will drift.
They already did:
[[2026-13-01]] matched the mobile frontend’s ^\d{4}-\d{2}-\d{2}$
shape regex, the command then fed 2026-13-01 into the strict date
parser, and the user got an invalid date slug toast for what
should have been a regular page.
The canonical entry point is
outl_actions::resolve::open_or_create_by_ref(target) (re-exported at the crate root).
It runs the whole decision tree in one place:
- Date-shaped target → journal (semantic validator, not the regex
shape —
2026-13-01falls through). - Literal slug match → existing page (clean slug from the picker).
- Slugified slug match → existing page (
[[avelino/outl]]findspages/avelino-outl.mdeven if the ref was typed before the page existed). @-prefixed mention sugar ([[@avelino]],[[@Thiago Avelino]]) — strip the@and resolve via steps 1-3 against the bare name. When nothing matches, create the page and settype:: personautomatically so the next@mention surfaces it in the autocomplete popup without any property editing. The@is purely the link affordance; page identity does not carry it.- Case-insensitive title match → existing page.
- Fallback: create a fresh page via
open_or_create_by_name(slugifies disk path, keeps the typed string as title).
@ mention autocomplete
Every client surfaces a person picker on a word-initial @.
The popup is filtered to pages where type:: person is set, ranked through the shared outl_actions::search_persons(query) helper owned by the person module.
The TUI calls it directly; desktop and mobile expose it as the search_persons Tauri command.
Accepting a candidate inserts [[@<title>]], a regular wikilink whose target carries the @ as a visual prefix only.
Mentions ride every existing page-ref code path (render, roundtrip, navigation), so adding the trigger costs no new render/matching/reconcile branch.
A person’s backlinks panel surfaces both forms ([[avelino]] and [[@avelino]]).
backlinks_for_page scans the @-prefixed alias when meta.page_type == Some("person").
Plain pages do not, so a stray [[@projeto]] in a non-person page’s backlinks does not trigger false positives.
Every client that turns a tap on a ref / tag / picker entry into a page view should wrap this single helper.
There is no client-side discrimination to maintain.
The open_or_create_by_name(name, kind) variant stays for callers that already know they want a regular page (no date branch).
Deep links (outl://)
The outl:// scheme, its one parser (outl_actions::parse_deep_link), and each client’s wiring live in deep-links.md.
Adding a new client
The pattern is small:
- Take a dependency on
outl-core,outl-md,outl-actions. - Open a
JsonlStoragerooted at<workspace>/ops/, or bring your ownStorageimpl. - Open a
Workspacewith that storage; hold oneHlcGeneratorper device. - Call into
outl-actionsfor every user-visible mutation. - Call
outl_actions::apply_journal_md(or the per-page equivalent when we add it) if you want the.mdprojection on disk.
What you write in your client crate: command surface (Tauri, keyboard, HTTP, …), UI state, navigation, animations. Nothing else.