← All posts

Plainva 0.6.2: the mobile app, rebuilt — and the gesture no test could see

August 8, 2026

The mobile app shipped features for eight planning rounds. Every desktop function got a screen, every question got a sheet, every surface got its own CSS. No round cleaned up after the one before it.

I counted, finally: 24 screens. 24 sheets, stacked up to four deep at the worst point — four sheets to set one property. mobile.css had grown to 3,361 lines, as large as the entire shared stylesheet that both shells were supposed to use. Of 25 shared UI primitives, the phone used two classes.

That is not a design problem or a feature problem. It is a missing layer: nothing decided where things belonged, so every screen decided for itself.

The clearest single number: 26 screens rendered one header component, exactly one rendered the other — and that one was the start screen. So every single navigation step changed the title size, the weight, the border and the shadow at once. The header that was actually meant to be the app’s, with proper scroll elevation, was dead code.

Twenty things the phone could not do

I wrote them down before rebuilding, because “the mobile app feels worse” is not something you can fix. This is:

No import — someone moving from Notion could not start on a phone at all. No attachments and no image viewer, so a photo pasted into a note was invisible afterwards. The calendar was read-only: no creating, no editing, no recurrence, no attendees. No reply-all, no forward, no attachments on outgoing mail, no draft — an unfinished message was simply gone when you left the screen. Relations not configurable. No row menu in databases. Five of seven task filters missing. Two slash commands that appeared in the menu and did nothing at all.

All twenty are closed in this release. The phone now runs the same import with the same twenty-seven sources, writes to the calendar including recurrence and attendees, forwards mail with attachments, and lets you build a database rather than only look at one.

The structure underneath: three surfaces instead of twenty-four screens — navigator, work surface, context. It is the desktop’s spatial model made temporal. And on a tablet it stops being temporal again: the adaptive layout is the desktop layout, three columns side by side.

The gesture that never worked

Here is the part I keep thinking about.

The first interaction rule of the new design says: a tap opens, a swipe runs the row’s action. It was built. It was tested. It was in the screenshots.

On an actual phone, it never fired once.

The row didn’t declare touch-action, so the browser claimed every horizontal drag as a scroll gesture before the pointer handler ever saw it. One missing CSS property.

What bothers me is not the bug. It is why it survived: every check I run drives with a mouse. Unit tests, Playwright, the screenshot comparison — a mouse does not consult touch-action. The property is invisible to all of it. I had 230 screenshots of surfaces across five themes, and not one of them could have told me that the central gesture of the app was dead.

There is now a touch guard that drags with real pointer events, against the production bundle. It found the bug on the first run when I checked it in reverse.

The same review turned up a second thing of that shape: the mobile production bundle did not mount at all. Not a slow start — a white screen. Automatic chunking had split react-dom’s CommonJS wrapper and react-i18next’s interop call into two chunks that reference each other, and the caller ran before the initialisation. Lint passed. Type checking passed. 556 unit tests passed. The entire CI passed. The app was dead on launch.

The desktop got a production smoke test after a white window in 0.3.0. Mobile never got one. It has one now.

Two names that were one file

For anyone who never touches the phone, this release still carries something.

Google Drive resolves name queries case-insensitively. So looking up mobile App.md also returned Mobile App.md. Plainva took the first hit — which meant a push could write one note’s content over the other. Then the next full listing knew only one of the two names, so the twin looked remotely deleted and was removed locally. After every restore, again.

On Windows and macOS it is worse: there, both names are one file.

Plainva now accepts the byte-exact name only and reports the ambiguity instead of guessing — nothing is written, renamed or deleted on a near-miss.

And sync learned the difference between “temporary” and “final”. Every throw used to become the state error with the raw provider string next to it; a dropped request, a 503, a token caught mid-rotation read exactly like a revoked account. Since the backoff retries within five minutes, the red message was usually gone by the time anyone looked at it. Those now say neutrally when the next attempt is due. The third consecutive failure is what turns red — and a revoked account still turns red immediately, because an unrecognised failure counts as final. A wrong “fatal” is loud; a wrong “temporary” is silence, and silence is the failure mode that costs data.

At the desk

Tasks can be ticked off where you see them — in the month grid, the agenda, the all-day strip. That sounds small and was not: the toggle re-indexes, nudges the background worker, and creates the next occurrence of a repeating task. A second handler in the calendar would have meant it depends on where you tick whether the chain continues.

Tasks also get the inverse time treatment of events. A past event is over, so it dims. An overdue task is more urgent, so it is emphasised, and a future one recedes.


Plainva stays local-first: plain Markdown in a folder you own, Obsidian-compatible, AGPL. Download · Changelog