The markdown toolbar was meant to be a small feature. Buttons that wrap the selection in emphasis markers, insert a heading, start a list. A day of work, maybe two.

It took considerably longer, because inserting text into a field programmatically while someone is typing Korean is not the same operation as inserting text into a field.

Composition, and why a button press is not a keystroke

When you type in a Latin script, each keystroke commits a character. When you type Korean, Japanese, or Chinese, the input method assembles characters from a sequence of inputs, and the in-progress result sits in a *composing region* — text that is provisionally in the field, owned by the IME, and not yet finalised.

For Korean, a syllable block is built from consonants and vowels as you type, and it stays in composition until something ends it — a following character that starts a new block, a space, or an explicit commit.

The input method tracks that region by position. If you modify the text underneath it without telling the IME, its idea of where the composing text lives no longer matches reality.

Our toolbar buttons wrote directly into the field. The result, when a button was pressed mid-syllable, was the IME finishing its composition at a position that had moved: a duplicated syllable, a dropped one, or formatting markers landing in the middle of a character block.

It was intermittent in exactly the way that makes a bug hard to find. Press the button between words and everything is fine. Press it while a syllable is half-typed and you get corruption. Our own testing was in English, where the composing region is usually empty, so we could not reproduce what users were describing.

The fix is to finish the composition before making a programmatic edit — finishComposingText() on the input connection — so the IME commits what it has and releases its claim on that region before the text changes underneath it. After that the edit is an ordinary edit.

We now test the editor with a Korean IME as a matter of course. Testing text input in one script is testing one script.

Markdown that stays out of the way

The design constraint for the toolbar was that formatting should not become a step you do after writing, because a step you do after writing a diary entry at eleven at night is a step you do not do.

So the toolbar sits above the keyboard, offers a small set of things — emphasis, headings, lists, quotes — and does nothing else. There is no preview mode to toggle into and out of, because switching modes is exactly the interruption we were trying to avoid.

The underlying storage is plain markdown text. This is deliberate and it is about longevity rather than convenience: a diary is a long-lived document, and text with a few conventional markers in it remains readable in any editor for as long as text remains readable. A proprietary rich-text structure is a bet that our format outlives our app.

Two devices, one entry, no connection

The other substantial piece of work was offline editing, which is a normal condition for journaling. Trains, hotels, aeroplanes, late at night on bad wifi.

Writing offline is straightforward: the entry is local, it syncs when a connection arrives. The hard part is what happens when the same entry is edited in two places before either syncs.

Most sync systems resolve this by last writer wins. For a to-do item's completion state, that is fine. For a diary entry, it means one of the two versions of your writing is deleted, and you will not be told which.

ONEDiary does not do that. When an entry has diverged, both versions are kept, and the app says so. You get the entry with both texts available and a note that they were edited separately, and you decide. It is less tidy than a silent merge and it does not delete anything you wrote.

For structured fields on the entry — mood, tags, the place — divergence is resolved automatically, because those are small values where losing one is not losing writing. The rule is that text is never merged automatically and metadata is.

We also do not use device clocks to order changes. A phone with a manually set clock is common enough to matter, and it would otherwise win or lose every conflict on the strength of being wrong.

Attachments have their own schedule

Photos are large and text is not, so they sync separately, and the earlier version let a record report itself as fully synced while its images were still queued. If the device was wiped or the app reinstalled in that window, the image was gone and the entry pointed at nothing.

An entry's sync state now accounts for its attachments, so "everything is backed up" means everything. Local copies of images are kept until the upload is confirmed rather than on the assumption that it succeeded.

This is the same class of mistake as the empty-list problem in another of our apps: reporting a state we had not verified because the happy path made it look true.

Insights are computed on the device

The refreshed insight and report screens summarise entries over a period — how often you wrote, what moods recurred, which tags cluster, when the gaps were.

All of it is computed locally from your own entries. Nothing about the content of a diary leaves the device to produce a summary, and the summaries are not stored anywhere but here.

We are also careful about the register. A number about your own writing habits reads as a judgement very easily, and a diary is the last place that should happen. The screens describe rather than evaluate: how many entries in a period, which tags recur, when you wrote most. There is no target, no streak, and no comparison to a previous month framed as better or worse.

A pattern is a prompt to look at the entries, not a conclusion about the month. The entries are where the meaning is; the summary is an index.

What we would do differently

The composition bug should not have been possible to ship. We build an app used substantially in Korean, and our editor testing was in English because that is what our test devices defaulted to. That is not a subtle oversight, and the fix — a device configured with a Korean IME in the regular test rotation — was a morning's work once we knew.

The general form: if your users type in a script your tests do not, your text editor is untested.