Most writing software makes a quiet assumption: your words belong on their servers. Loomdraft starts from the opposite premise. Your manuscript is a folder of plain Markdown files on your own machine. The application is a shell, fast and capable and entirely optional. Delete it tomorrow and your words are still there, readable in any text editor that has ever existed.
Getting there required a specific set of architectural decisions, and each one has consequences that run all the way down the stack.
01. THE PLATFORM CHOICE
The modern web is a capable runtime, but building a privacy-first application on top of it introduces a structural tension. Browser-based apps implicitly reach toward the network for authentication, for sync, for storage quotas that assume cloud backup. Even with service workers and IndexedDB, the model assumes connectivity.
Tauri resolves this tension by using the OS webview as a rendering surface while the application logic runs in Rust. The result looks like an Electron app from the outside: a native window, file menus, OS-level shortcuts. But without the 150MB Chromium bundle. A production Tauri binary ships under 15MB.
// src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
read_project,
write_document,
search_fulltext,
export_manuscript,
])
.run(tauri::generate_context!())
.expect("error running Loomdraft");
}The invoke handler pattern is the boundary between worlds. React components call invoke("write_document", { path, content }) and Rust handles the actual filesystem I/O. The frontend never touches the disk directly. All file access goes through typed Rust commands that validate paths, handle errors, and enforce the application's data model.
02. THE DATA MODEL
Every Loomdraft project is a directory tree. Open the project folder in Finder and you see exactly what you'd expect: folders named manuscript/ and kb/ containing .md files with YAML frontmatter.
my-novel/
├── manuscript/
│ ├── chapter-01.md
│ ├── chapter-02.md
│ └── scenes/
│ ├── opening.md
│ └── inciting-incident.md
├── kb/
│ ├── characters/
│ │ ├── protagonist.md
│ │ └── antagonist.md
│ └── locations/
│ └── city.md
└── .app/
├── index.db ← SQLite FTS5 index
└── backups/ ← Auto-saves (up to 20 per file)The frontmatter on each document carries just enough metadata to reconstruct the tree:
---
type: chapter
title: "The First Night"
order: 1
created: 2026-01-12T09:14:00Z
wordcount: 4823
---Fourteen document types are supported: chapters, scenes, acts, characters, locations, factions, items, lore, timelines, notes, outlines, templates, references, and miscellaneous. The type determines the icon in the sidebar, the default fields in the frontmatter, and where in the tree the document is permitted to nest.
TIP
Because the project is plain files, it's trivially Git-committable. Many writers using Loomdraft run a daily git commit -am "writing session" to get automatic version history on top of the app's own backup system.
03. THE FRONTEND LAYER
The UI is React 19 with TypeScript, built by Vite. Component architecture follows a straightforward split: layout components (sidebar, editor shell, toolbar), feature components (document tree, search overlay, theme picker), and editor primitives (the CodeMirror instance and its plugins).
The sidebar tree is the primary navigation surface. It maintains a flattened representation of the project hierarchy, supports drag-and-drop reordering, and updates optimistically on any write operation. The Rust backend confirms asynchronously, and the tree reconciles if there's a discrepancy.
State is managed locally, no Redux, no Zustand. Each feature area owns its state through React's useReducer and context, kept shallow enough that prop drilling is rarely a problem. The editor is the sole exception. It maintains a complex internal state covering cursor position, selection, and undo history that lives entirely within the CodeMirror instance and is never surfaced to React's state tree.
04. THE IPC BOUNDARY
Every filesystem operation crosses the Tauri IPC bridge. The bridge is typed end-to-end: TypeScript interfaces on the frontend match Rust structs on the backend, with serde handling serialization.
// src/lib/tauri.ts
interface WriteDocumentArgs {
projectPath: string;
relativePath: string;
content: string;
}
export async function writeDocument(args: WriteDocumentArgs): Promise<void> {
await invoke<void>("write_document", args);
}// src-tauri/src/commands/documents.rs
#[tauri::command]
pub async fn write_document(
project_path: String,
relative_path: String,
content: String,
) -> Result<(), String> {
let full_path = Path::new(&project_path).join(&relative_path);
fs::write(&full_path, content)
.map_err(|e| e.to_string())?;
Ok(())
}Auto-save fires every ten seconds from a React useEffect that diffs the current editor content against the last persisted snapshot. If there's a change, it calls writeDocument, updates the word count in the sidebar, and queues a FTS index refresh.