gavinbowden.me — home

Inkblot

Inkblot is the writing app I wanted and couldn't find: a Tauri + React desktop app where documents, notes, and planning canvases all live in plain files on my own disk. No accounts, no sync servers, and the heavy features (dictation, read-aloud, rhyme suggestions) run on models that live on the machine. I'm still building it, so this page is where it's currently at.

A project is just a folder

Every writing app I tried wanted an account. Several wanted a subscription. All of them wanted my drafts sitting on someone else's server, which is a lot to ask for something that is, at the end of the day, a text file.

So, I made project a folder, and a document a JSON file inside it. The file tree in the sidebar isn't a metaphor for the disk. It is the disk.

That decision hands me one feature for free. A project can live anywhere, including an iCloud or OneDrive folder, which gets me syncing across machines without running a single server of my own.

Inkblot keeps drafts, character notes, and mood boards in that one place. The front end is React 19 on Vite with TipTap as the editor, and the Rust side handles everything a webview can't: the microphone, supervising the speech models, and trackpad haptics (swiping between sidebar tabs ticks the trackpad through NSHapticFeedbackManager, which the webview has no API for).

What's in it so far

  • A TipTap editor with custom nodes, including resizable images, tables, embedded Pinterest boards, and an iMessage-style conversation tile for writing text-message dialogue. The conversation tile saves a static HTML snapshot alongside its data, so a document still displays it without mounting the interactive view.
  • Notes anchored to a quote in the text, marked by a colored dot at the end of the passage. The dots are ProseMirror decorations, and they only get rebuilt when the set of notes actually changes, not on every keystroke.
  • Focus mode, a fullscreen overlay that shows the draft one sentence at a time. It's only a view: every edit still goes through the real editor underneath, and the overlay just moves its selection.
  • Writing tiles for synonyms, definitions, parts of speech, and rhymes. The rhyme tile narrows candidates by shared suffix, then ranks them with a small ONNX model running in the webview.
  • Story folder presets (Characters, Settings, Outline, Novel) that give matching folders their own icons and a "New Character" style button in the file tree.

Version history without a server

Google Docs colors each edit by the collaborator who made it. Inkblot only ever has one writer, so it colors edits by sitting instead: every launch of the app gets a session id, hashed to a stable hue, and each checkpoint carries it.

The history panel groups checkpoints by day and then by bursts of editing (a 15-minute gap starts a new burst), and it can compare any two versions side by side with a word-level diff. Merging works per paragraph, so I can take one paragraph back from last Tuesday without undoing the rest of the week.

Each document keeps its 200 most recent automatic checkpoints, and pruning skips named versions, so the ones I bothered to name never age out.

The canvas, and three decisions

The biggest feature so far is an infinite canvas for outlining and mood boards: cards, images, audio, links to other documents, groups, and labeled edges between them. Canvas files use the open JSON Canvas format, so another JSON Canvas app can open them too.

Three decisions carry most of the weight, and all three amount to doing less work at the exact moment it would feel natural to do more:

  • Undo stores whole-document snapshots, not inverse patches. The reducer never mutates, so an untouched node keeps its object identity, and a snapshot costs one new top-level object plus pointer copies instead of a deep clone. Patches are a bug farm for compound edits: deleting a node also deletes every edge touching it, and undo has to bring all of it back as one step.
  • Nothing gets dispatched mid-drag. Snap targets are computed once at pointer-down, alignment guides are painted straight to the DOM, and the move lands in state once, on pointer-up. That keeps a drag at frame rate on a big canvas and makes it exactly one undo step.
  • Canvas images are content-addressed (the filename is a hash of the bytes), so pasting the same picture twice writes one file. Text documents inline their images as base64, but twenty photos on a canvas would have meant a multi-megabyte file to re-parse on every open.

Speech, both directions

Dictation is my inkblot-dictation crate, pulled in as a Git dependency with the Metal backend turned on. I split it out so this codebase didn't have to carry a speech-to-text pipeline, and the app side is a thin set of Tauri commands plus a level meter for the mic indicator. Read-aloud goes the other way, using the Kokoro TTS model. The Rust side keeps a long-lived Python worker running Kokoro and talks to it over stdin and stdout, so the model loads once instead of once per sentence. If there's no usable Python, it falls back to kokoro-js running in WebAssembly. Text is split at paragraph, sentence, and clause boundaries because Kokoro gets worse on long inputs, and a map from characters back to document positions lets the editor highlight each word as it's spoken.

Where it stands

Most of the app can only be exercised inside the Tauri webview, so automated tests cover the pure-logic parts, where bugs are quiet and expensive. There are thirteen headless suites for the canvas: persistence, viewport math, the reducer and undo, edges, groups, snapping, search, and a jsdom mount of the real canvas surface. The Tauri modules are stubbed with an in-memory filesystem, so the real storage code runs against them instead of a reimplementation.

I ran them while writing this page. The canvas search suite fails.

So that goes to the top of the list. Behind it: the synonym model still needs work before it earns a place next to the rhyme tile, the text editor needs the coverage the canvas already has, and the whole thing needs packaging and signing before anyone but me can install it.