| version | alpha | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | outl | |||||||||||||||||||||||||||||||||
| description | Local-first outliner. What happens, to whom, under which circumstances, and what we say when we can't. | |||||||||||||||||||||||||||||||||
| users |
|
|||||||||||||||||||||||||||||||||
| contexts |
|
|||||||||||||||||||||||||||||||||
| principles |
|
|||||||||||||||||||||||||||||||||
| surfaces |
|
DESIGN.md answers what it looks like.
This file answers what happens, to whom, under which circumstances, and what we say when we can't.
It is written as context rather than as a report — the UX-context design framing: research findings become constraints, interaction patterns become rules, and the domain vocabulary is written down so three surfaces stop inventing three names for one operation. The audience is a contributor or an agent about to build something user-visible, and the test of every sentence here is whether it decides a question that would otherwise be decided by whoever types first.
The one rule that outranks every interaction preference:
A user-visible fact has exactly one owner, and every surface reads it from there. Root
CLAUDE.mdinvariants 12 and 13.
That owner is a Rust function whenever one can hold it — outl_shortcuts::support for "can I do this here",
outl_actions::refusal for "who tells the user we declined", outl_actions::reminders::next_fire_at for
"when does this fire".
A second opinion in TypeScript or Swift is not a convenience; it is drift that reaches the user before it
reaches a test.
| The question | Owner |
|---|---|
| What colour is this, how big, how round, how far apart | DESIGN.md |
| What happens when I press it, and what if it can't happen | this file |
| Which chord reaches which action | docs/shortcuts.md |
| Which client performs which action, verbatim | docs/client-parity.md — generated |
| How a client is wired to the shared crates | docs/clients.md |
| How mobile behaves, feature by feature | docs/mobile-ux.md |
| Why a design was chosen over its alternatives | docs/rfcs/ |
The two files overlap in exactly one place, deliberately: an affordance has a look and a behaviour.
When that happens the visual half stays in DESIGN.md and this file links to it — the hidden-until-hover
fold chevron is specified as a layout rule there, and appears here only as the reach question it raises.
One person, thinking in nested bullets, on more than one device, who already lost notes once.
They are comfortable with a keyboard-driven tool and with the filesystem.
They expect to be able to grep their notes, open one in vim, and have outl notice.
They are not a team: there is no shared workspace, no permissions model, no "who changed this" question
that resolves to another human — docs/why-outl.md owns that boundary.
The workspace this product is actually daily-driven against is ~2,500 pages and ~64,000 blocks, migrated out of Roam. Every performance and scale decision in the product is calibrated to that, not to a fresh workspace — see RFC 0128 and RFC 0137. A feature that is pleasant at 50 pages and unusable at 2,500 has not shipped.
They arrive fluent in a vocabulary we did not invent, and we do not rename it:
[[ref]], #tag, the daily journal, Tab / Shift+Tab to indent, ((block-ref)), zoom-into-a-block.
Roam and Logseq taught them these, and a gratuitous rename costs a returning user more than a better name
earns them.
Where we differ from their muscle memory, the difference is load-bearing and stated —
id:: lines are gone because the markdown is theirs
(root CLAUDE.md invariant 2), not because a different syntax looked nicer.
In order of how fast it ends the relationship:
- Work disappeared. Not "is hidden", not "is on the other device" — gone.
- Two devices disagree and nothing says so. A silent divergence is discovered weeks later, by which time there is no memory of which side was right.
- The markdown stopped being theirs. Metadata in the file, a proprietary re-encoding, an unreadable diff.
- The tool interrupted them. A modal on launch, a buzz they did not ask for, a keystroke that blocked on a disk write.
- A key did nothing. Not an error — nothing. Indistinguishable from a bug in the tool, a bug in the terminal, or a bug in their own fingers.
Every principle below traces to one of those five.
Context is not decoration around the interaction; it is what decides whether the interaction is correct. Each row below changes what a correct answer is.
| Context | What it changes |
|---|---|
| Offline, partitioned, for days | There is no server to ask, no "sync now" that can succeed. Every operation must be complete locally, and convergence is something that happens later without asking. |
| Two devices edited the same page offline | The merge cannot be a dialog — nobody is there to answer it. It resolves deterministically through the tree CRDT (docs/crdt.md), and a move that would create a cycle is a no-op on the tree while staying in the log. |
| Asleep | A fire landing inside quiet hours is pushed to the end of the window, never dropped. You asked for it; you get it, just not at 3am (docs/reminders.md). |
| A terminal, possibly over ssh, possibly inside tmux | There is no OS appearance API, no notification centre worth the name, no background presence. mode = "auto" resolves to the dark side and says so; a reminder due with the TUI closed is lost to that client, and that is recorded rather than papered over. |
| A phone | There is no outl binary and no git. Recovery copy that says "run outl reconcile" is wrong here, so mobile's banner says to open the workspace on a computer. Automatic backups do not exist on iOS for the same reason. |
| A soft keyboard covering half the screen | Chords are not available. Every action a phone user needs is reachable by touch, or it is declared missing with a nudge saying what to do instead. |
| ~2,500 pages, ~64,000 blocks | Any list with no natural bound needs one: a namespace's mentions are capped at 50 with the real total beside it, because 3,221 of them is an unreadable panel and ~292 KB on the IPC per page open. |
| An external editor touched the file | vim, Obsidian, a script, a peer still on an older binary. The parser never drops a line it does not understand; it keeps it as a block and records a warning. A page that cannot be read at all is never overwritten. |
| The process died mid-write | The op log is written synchronously, the .md after. A crash between them leaves the file briefly behind the log — recoverable by construction, because the log is the source of truth and the .md is a projection. |
| The user is in the middle of a sentence | Nothing that is not the user's own keystroke may take focus, steal the cursor, or block the frame. Hot-reload refuses to clobber an in-flight edit and writes to the status line instead. |
These are the tie-breakers. When two reasonable designs disagree, the one that satisfies the earlier principle wins.
The product opens on today. Not a dashboard, not a file tree, not a "welcome back" screen — the journal for today's date, cursor ready. A page you have to navigate to is a page you write in less.
Every client is async-on-write: the op log write is synchronous, the .md projection is not, and no
keystroke, command reply, or frame waits on a render, an fsync, or a backlink rebuild
(docs/clients.md → Async projection writes).
The same rule governs everything that is not the user: automatic backups run on a background thread after a
startup delay, and never on the edit path, the quit path, or an idle hook the render loop waits on.
Plugin onOp hooks are fire-and-forget so a slow plugin cannot stall the next keystroke.
The user-visible consequence is deliberate and worth stating: pressing Esc does not mean the bytes are
on disk yet.
It means the op is in the log, which is the thing that survives.
The TUI drains the write the moment you pause, forces it if you keep a burst going, and always flushes
before quitting, Ctrl+S, navigation, or a peer reload — docs/tui.md
owns the timings.
A refusal that reaches only a log line is the bug, not the fix.
outl declines writes on purpose in several places — that is principle 4 — and every one of those refusals
is owed to whoever asked for the write.
outl_actions::refusal is an exhaustive match over (refusal, surface) for exactly this reason: a new
surface does not compile until every refusal declares what it does there, and the table in
docs/clients.md is generated from it.
The surfaces are five — TUI, desktop, mobile, CLI, MCP — because a refusal is owed to a program
calling the MCP just as much as to a human, and a program will retry where a human would notice
(RFC 0255).
A banner that outlives its condition is the same defect mirrored. Only the open commands run the ahead-of-log check, so the first checked reply with no notice clears the banner — never a mutation reply, which would clear it on the user's first edit, the exact action the banner warns against.
When outl cannot tell whether a write would destroy content, it declines and says so.
The canonical case: a page's .md holds lines that exist in no op.
Overwriting it projects the tree over content the log never saw, and those bytes are gone.
So the write is withheld, the page still opens showing what is on disk, and the user is told — the cost
being that the page is frozen in both directions until outl reconcile --ahead-of-log runs
(root CLAUDE.md invariant 8, RFC 0210).
The trade is stated because it is not free: a stale view is recoverable, deleted bytes are not.
The same reasoning gives the TUI its cannot read <path> … editing disabled state — a failed read parses
as an empty document, and without the guard the next commit renders that emptiness back over the page and
sends every block to the trash on every device.
Deletion itself follows the rule: a deleted block is moved to TRASH_ROOT, never physically removed.
Before adding state, ask whether two devices can disagree about it and whether they should reconcile.
- Yes → it is an
Op. Fold state isOp::SetCollapsed; a snooze isOp::SnoozeRemind. - No → it never leaves the device. Zoom is pure frontend state that never reaches Rust. Theme choice, backlinks order, quiet hours and the enabled flag live in device-local config. "This device already fired that reminder" lives in a workspace dotfile that no transport replicates.
The reminders split is the clearest statement of the rule: snoozing on the phone must silence the laptop, and the phone having buzzed must not stop the laptop from buzzing. Getting this backwards produces either a note that syncs your scroll position or a snooze that only worked on the device you were holding.
A [[date]] alone never schedules anything.
People use dates for backlinking, and the moment a link becomes a buzz, the linking stops.
Notification permission is asked at the first actual fire, not when a switch is flipped.
The same instinct governs the rest of the product: no required tags, no mandatory daily-review modal, no onboarding that must be dismissed before the first bullet.
Chrome that explains the outline — fold chevrons, gutters — is quiet at rest and appears on hover, focus
or selection.
Chrome that is the outline — the bullet, the indent guides — is always visible.
The specification, including why an indent guide must not fade and why :focus-within is load-bearing
rather than decorative, is in DESIGN.md → Layout.
Covered above, and the whole reason this file exists as a separate document rather than as prose spread across three client crates.
Five channels, and picking the wrong one is how a real problem becomes invisible.
| Channel | Use it for | Lives for | Wording owner |
|---|---|---|---|
| Status line (TUI, desktop) | The thing you just tried did something other than what you expected — a gap, a partial, a refused save | Until the next message | outl_shortcuts::support for gaps; the ActionError for refusals |
| Toast | A transient outcome you did not initiate — a peer reload declined, a backup failed | Seconds | The error's own Display |
| Sticky banner | A condition that persists and blocks convergence — a page ahead of the log, parse warnings on the open page | Until the condition clears, verified by a checked reply | @outl/shared/warnings |
Structured error (CLI --json, MCP) |
A program asked and a program must be able to tell "this page stopped syncing" from "that failed" | The reply | ActionError's Display, forwarded verbatim |
| Nudge | The user reached for something this client does not do | Until the next message | the catalog, never the client |
Two rules cut across all five.
One condition gets one sentence — the MCP forwards ActionError's own Display rather than writing
a second sentence for the same thing, because two owners of one sentence is two sentences that drift.
And a console is not a surface: the desktop dispatcher once logged console.warn for an unhandled
action, in a comment that called DevTools output something "the user sees".
Not every client does everything, and that is fine. What is not fine is a key that does nothing, because the user cannot tell a gap from a bug.
outl_shortcuts::support(action) is the single owner — an exhaustive match over every action in the
catalog × every client — with outl_shortcuts::capability_support doing the same for the capabilities that
have no chord at all (page history, the plugin marketplace, the calendar, assets, peer pairing…).
A new variant of either does not compile until every client declares its verdict.
The five verdicts, and what each one promises the user:
| Verdict | Means | Example |
|---|---|---|
Full |
The user can do it here. Chord, button or gesture — the distinction is not the user's problem | — |
Native |
The platform does it, outl has no handler and needs none | Backspace on an empty textarea |
Partial |
Reachable, not with the full semantics the name implies | "No character cursor on the desktop, so a behaves like i." |
Missing |
Should exist here, doesn't yet. Says what to do instead | "Pinning a page isn't on the desktop yet — pin it from the TUI with g P." |
NotApplicable |
Cannot exist here by construction. Says why | "Mobile is single-pane — the page switcher replaces the sidebar." |
Native is its own state rather than a boolean's rounding error: "reachable" and "has a handler" are
different questions, and a client test asserting "every Full action has a handler" must not demand one
where there is nothing to hand off to.
A chord with no handler falls through without preventDefault — the textarea or the OS still gets the
key — and surfaces the nudge.
Swallowing the key and saying nothing is the worst of both.
Confirm what cannot be undone from inside the product, and nothing else.
Destructive intent is signalled visually with its own colour role (destructive, never warn — see
DESIGN.md → Roles), and destructive gestures are deliberately harder than their
neighbours: deleting a page from the mobile switcher is a sustained long-press that cancels if the finger
moves, then a confirm.
Journals are excluded from that path entirely.
Everything reversible skips the dialog. Block deletion is a move to the trash, so it is undo, not a question.
Users conflate these; the product must not.
| The question | The answer | Scope |
|---|---|---|
| "Undo that" | outl_actions::history — bounded snapshot stacks, each remembering selection and cursor |
This session |
| "What did this page say last Tuesday" | outl_actions::timeline → outl page history / the desktop ⏱ panel. Read-only on every surface |
The op log |
| "A truncating edit ate my text" | outl recover, restricted to a provably-additive rule |
One narrow case |
"This .md holds lines the log never saw" |
outl reconcile --ahead-of-log |
One page |
| "Give me the whole folder as it was" | outl backup restore — device-local git snapshots outside the workspace |
The workspace |
History is read-only on purpose: a general "put this back" button needs its own safety argument, and does not have one yet. 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 opened it to find. And not every op is an event: folds, snoozes, and re-emitted ops that changed nothing are skipped, because a reconcile produces them in volume and a real edit was once buried under six of them.
Tapping [[avelino/outl]], #code-review, [[2026-06-04]] or a picker row must never split the
journal-vs-page decision between a frontend regex and a backend parser.
outl_actions::open_or_create_by_ref runs the whole decision tree in one place, and the behaviour it
guarantees is that a ref always lands somewhere — an existing page, a journal, or a page created on
the spot with the typed string kept as its title.
This is not a hypothetical split.
[[2026-13-01]] once matched a frontend ^\d{4}-\d{2}-\d{2}$ shape check, hit the strict date parser,
and produced an invalid date slug toast for what should obviously have been a regular page.
The @ mention is the same path with one visual affordance: a word-initial @ opens a picker over pages
with type:: person, accepting one inserts [[@name]], and the @ is presentation only — page identity
never carries it.
A person page that does not exist yet is created with type:: person set, so the next @ finds it
without anyone editing a property.
Zoom makes one block the outline root. It is local view state (principle 5), resolved against the live outline on every render — so an edit inside the zoom stays reflected, and a target that was deleted or moved away falls back to the full page instead of showing an empty screen. Switching pages drops it.
On touch the gesture budget is small and every tap target is spoken for: the plain bullet dot zooms, the TODO checkbox cycles state, the collapse triangle folds. Adding a gesture means finding one that is free, or moving an existing one somewhere still reachable — zoom took the plain dot's old mark-as-TODO tap, and TODO moved to the long-press menu rather than disappearing.
The reply a client renders is built from the in-memory tree, not from a re-read of the file it just queued. Reminders are scanned from the tree for the same reason: reading the projection would have been stale by construction, and the user who just pressed "remind me" would open the list to find nothing.
User-facing strings are English today (i18n is later, and is not a reason to write English badly). The rules below are enforced by tests, not by review.
Say what the user can do instead.
A nudge is not an apology and not a status report.
Missing and NotApplicable states carry a sentence that names the alternative or the reason.
Never leak developer vocabulary.
unimplemented, not implemented, todo, fixme, no handler, handler, dispatcher — banned by
DEV_WORDS, checked against every nudge in the catalog.
A gap explained in the implementation's words is the same failure with a nicer transport.
Be long enough to help. A nudge under 20 characters fails its test. "Not supported" tells the user nothing they had not already worked out by pressing the key.
Name the platform's actual reality.
The ahead-of-log banner says "run outl reconcile --ahead-of-log in the workspace folder" on desktop and
"open the workspace on a computer" on mobile, because there is no outl binary on iOS.
Copy that points at a terminal that does not exist is worse than no copy.
One condition, one sentence, one owner.
@outl/shared/warnings::aheadOfLogNotice owns the GUI wording; the MCP forwards the Rust Display
verbatim rather than growing a second copy.
The domain words, as the user means them.
Definitions live in docs/concepts.md; this is the list a feature author needs so a
new surface does not invent a synonym.
Workspace — a directory. One outl process is bound to one; there is no workspace switcher.
Page — one .md file holding one outline. Named by title::, filed by slug.
Journal — a page keyed by date. The default landing surface.
Block — one bullet. The unit of reference, movement, undo and sync.
Property — key:: value on a page (top of file) or a block (nested under it).
Ref / tag — [[page]] and #page resolve to the same file and both count as backlinks; the
difference is presentational.
Mention — [[@name]], a ref whose @ is an affordance, resolving to a type:: person page.
Namespace — a / in a page title. os lists and collects os/linux; the slug on disk is flat.
Backlink — a block that points here. Distinct from nested pages, which are pages that live under
here; the two come from different sources and a client can ship one without the other.
Sidecar — the .outl JSON next to each .md, holding block ids. Never visible in the markdown.
Op log — the append-only truth. One file per device.
Zoom / focus — one block as the outline root. Local, never synced.
Actor — one device's write identity. Users never see the word; they see "this device".
The same operation is spelled three ways, and this is settled, not pending:
| Surface | Convention | Example |
|---|---|---|
| MCP tools | noun_verb |
outl_page_delete |
| CLI subcommands | noun verb |
outl page delete |
outl_shortcuts::Action |
VerbNoun |
DeletePage |
Each is internally consistent; renaming any of them breaks scripts and agent configs to make guessable names guessable (RFC 0255 → Why not the alternatives). What is not optional is that a new operation follows its surface's convention.
- "Note" for a block or a page — the user has pages and blocks; "note" collapses the distinction the whole product is built on.
- "Save" as a user action — writes are automatic (principle 2).
Ctrl+Sexists to force a flush, not to be the way work persists. - "Sync now" as a promise — there is no server to ask. Peers converge; a button that implies a request/response is lying about the architecture.
- "Conflict" for anything the CRDT resolved — a converged move is not a conflict the user must adjudicate. Reserve the word for the ahead-of-log case, which genuinely needs them.
- "Delete" for a block — it moved to the trash. Say so if the distinction matters in context.
The verbatim answer is generated and test-pinned in
docs/client-parity.md — every action and every capability across TUI, desktop
and mobile, regenerated with:
OUTL_UPDATE_PARITY_DOC=1 cargo test -p outl-shortcutsThe shape of the divergence, and why each one exists rather than being a backlog item:
| Divergence | Why it is not a gap |
|---|---|
| Mobile binds no chords | Touch and a soft keyboard. docs/shortcuts.md leaves the column blank rather than inventing a row. |
| The desktop has no character cursor inside a block | Its vim mode holds a selected block id, not a caret. Ten char-level ops surface one shared nudge written once in the catalog. |
mode = "auto" resolves dark on the TUI, always |
A terminal has no OS appearance API, so the gap is declared rather than guessed at. DESIGN.md → Theming owns the reasoning. |
| No automatic backups on iOS | There is no git binary. |
| The TUI can miss a reminder | A terminal session has no background presence. Recorded rather than papered over. |
Only the desktop resolves keystrokes through outl_shortcuts::lookup() |
The TUI still dispatches Normal mode from its own match. This one is open work rather than a settled divergence, and it is why a chord added to the catalog does not automatically reach the TUI. |
A capability that exists on no client is not in the catalog at all — the catalog answers "who does not have this", not "what could we build".
The visual half — contrast obligations, reduced motion, selection never being colour-only — is specified
in DESIGN.md → Accessibility.
What belongs here is whether an affordance can be reached:
- Hidden chrome must stay keyboard-reachable. Chrome revealed on hover is also revealed on
:focus-within; without that, "quieter at rest" silently means "mouse-only". - Every icon-only control is labelled with what it will do to what —
Delete page "{name}", notDelete— because a label read out of context is the only context a screen-reader user gets. - Decorative structure is hidden from assistive tech: an indent guide is
aria-hidden, so nesting comes from the DOM tree rather than from a column of spacers. - A message must land somewhere the user is looking. The status line and the banner qualify; a console does not.
- Touch targets are sized for a fingertip and gestures never overlap — see Zoom above.
Research synthesis, in the only form that survives: incidents with numbers, and the behaviour each one changed.
| What happened | What it changed |
|---|---|
A fix for "tree ahead of .md" (#166) re-projected whenever a hash matched. On a 2,560-page workspace that deleted 233 pages holding 1,426 lines that existed in no op — and doctor --repair printed 708 fixed while doing it |
Principle 4. A hash match proves outl wrote the file last, not that the log holds its content (RFC 0210) |
| That refusal then reached only a backend log line — the page appeared to freeze with nothing said | Principle 3, and the five-surface refusal matrix (RFC 0255) |
"Does the desktop bind y r?" had three answers in three docs, all stale in different directions. Both y r and : were dead keys logging to a console |
outl_shortcuts::support, the generated parity table, and the nudge (RFC 0253) |
#os/linux parsed, resolved, and did nothing a hierarchy implies. When the listing shipped it read title:: — and a real workspace had 14 titles across 2,575 pages, so it rendered empty everywhere while the backlink half credited 3,221 blocks under one namespace |
repair_namespaced_titles, plus the 50-item cap on namespace mentions (#275) |
"Only the GUI binds the iroh endpoint" was a policy, so a headless outl mcp serve machine synced with nobody, silently |
Root CLAUDE.md invariant 10 — when you change who decides, enumerate who was standing on the old decision (#220) |
[[2026-13-01]] matched a frontend shape regex and produced invalid date slug |
open_or_create_by_ref as the single decision tree |
| A block's real edit was buried under six non-events in its own history on a 64k-block workspace | timeline's "not every op is an event" rule |
The pattern across all of them is one shape: a fact with more than one owner, and nothing that could fail when the copies disagreed.
Do land a new user-visible fact in a Rust function that every surface reads. Don't mirror it in TypeScript "just for this client" — that is how a schedule fires at 3am on one device.
Do declare what every client does with a new action or capability. Don't ship a chord with no handler. A key that does nothing is indistinguishable from a bug.
Do write the nudge in the catalog, saying what the user can do instead. Don't write it in the client, and don't write it in the implementation's vocabulary.
Do refuse a write you cannot prove is safe, and tell every surface that asked. Don't let a refusal reach only a log line, and don't leave the banner up once the condition clears.
Do ask whether two devices can disagree about a piece of state.
Don't put converging state in a file with last-write-wins semantics, or local view state in an Op.
Do make interruption opt-in, once, explicitly. Don't turn a link into a notification.
Do keep the write off the input path. Don't block a keystroke, a reply, or a quit on a projection.
Do test a feature against ~2,500 pages before calling it done. Don't let a list grow without a bound and a stated total.
- Name it in the user's vocabulary, following the surface's convention (Glossary above). Check the word is not already taken by a different concept.
- Decide whether it converges. Two devices disagreeing →
Op. Otherwise device-local, and say which file holds it and what cleans it up (rootCLAUDE.mdinvariant 9). - Put the logic in
outl-actionsif more than one client needs it — before its first use, not after the second client copies it (docs/clients.md). - Declare the verdict for all three clients in
support.rs(it has a chord) orcapability_support.rs(it does not). The lesser states need a sentence that passes the vocabulary and length tests. - Decide what it says when it declines, and on which of the five surfaces. If it is a new refusal,
it is a row in
outl_actions::refusal, and the CLI and MCP are surfaces too. - Keep the write off the input path.
- Regenerate the tables (
OUTL_UPDATE_PARITY_DOC=1,OUTL_UPDATE_CLIENTS_DOC=1) and never hand-edit a generated region. - Run
/check— both halves. Two of the invariants behind this file are enforced by TypeScript parity tests.
If the feature is visual, DESIGN.md → Adding a token or a preset
is the other half of this checklist.