Architecture

The crates, the data flow, and why the pieces are shaped this way.

πŸ”—Crates

CrateResponsibility
ecr-coreWire types only β€” no I/O. Account, ThreadSummary, Message, PartMeta, TagOp, Query, Revision, Draft, Doctor.
ecr-storeEverything that touches the mail: config discovery, the MailStore trait, the notmuch backend, the SQLite mail index, MIME parsing, sync and send.
ecr-serveraxum: REST, SSE, bearer auth, the maildir watcher. A library β€” it builds no binary.
ecr-cliThe ecr binary. Owns the command surface and everything user-facing; deliberately free of GUI dependencies.
shellTauri v2 desktop and Android wrapper around web/dist, built as ecr-desktop.
webSolidJS client. The only UI code; every target renders it.

πŸ”—Configuration discovery

The single largest cause of my previous implementation's failure was hardcoded paths. Nothing is hardcoded now. Each tool's config is resolved in order:

  1. an explicit path in ~/.config/ecr/server.toml
  2. the tool's own environment variable (NOTMUCH_CONFIG, MBSYNCRC)
  3. the XDG location ($XDG_CONFIG_HOME/notmuch/<profile>/config, .../isyncrc, .../msmtp/config)
  4. the legacy dotfile (~/.notmuch-config, ~/.mbsyncrc, ~/.msmtprc)

The maildir root is read from the resolved notmuch config's database.path. It is never guessed from dirs::data_dir() β€” that is exactly how the old client ended up scanning a lowercase ~/.local/share/mail that does not exist while the real mail sat in ~/.local/share/Mail.

Files that exist but were not chosen are reported as shadowed, so a stale ~/.notmuch-config is visible rather than silently ignored. A file reached through two names β€” $NOTMUCH_CONFIG pointing at the XDG default, say β€” is the same file, not a stale copy, and is not reported: the candidates are deduplicated by canonical path, so a warning never tells the reader to delete the config they are using.

πŸ”—Managed configuration

By default ecr_store::paths reads the mail tools' configuration. Managed mode is the opt-in path where ecr writes it, and it is one direction only:

accounts.toml  ──render──>  ~/.config/ecr/managed/{isyncrc, msmtp/config,
   (ecr owns)                                      notmuch/config, notmuch/hooks/post-new}
                                     β”‚
                                     β”œβ”€ Env::candidates puts these first, and only
                                     β”‚  for a package whose management is "ecr"
                                     β–Ό
                          the existing MbsyncConfig / MsmtpConfig / NotmuchConfig
                          parsers, unchanged
                                     β–Ό
                          discovery::accounts β€” still the only answer to what
                          accounts exist

accounts.toml is an input. An account is still a directory under the maildir root, so an account described there and never synced is one ecr reports as missing rather than one it pretends to have β€” which is what keeps doctor, reply identities and the sidebar working exactly as they did. The renderers emit the shapes the parsers already read, PassCmd "ecr oauth token <profile>" included, so nothing downstream knows managed mode exists.

Three bounds make it reversible:

  • ecr writes only inside ~/.config/ecr/managed/. The reader's own files are never touched, and are what resolution falls back to the moment a package goes back to self β€” a candidate that is not a file is skipped, so even a half-finished managed setup degrades to the working one underneath it.
  • Every generated file carries a # ecr-hash: digest of its own body. A file that no longer matches was edited, and is moved aside rather than overwritten.
  • Create is Near, Expunge and Remove are None. ecr fetches what appears on the server and creates, removes and expunges nothing on it.

The switch itself is [packages.<tool>].management in the shared settings file, which is generated in TypeScript. The server parses only that one section (ecr_store::packages), and the two are pinned together by crates/ecr-store/settings/default.toml β€” written by the client's own generator as a file snapshot, parsed by a Rust test, and used as the seed when a machine has no settings file at all.

πŸ”—Freshness

notmuch count --lastmod returns <count> <uuid> <lastmod>. That (uuid, lastmod) pair is the Revision, and it is used three ways:

  • as the ETag on thread listings, so If-None-Match yields 304
  • as the invalidation token in SSE mail:changed / tags:changed
  • as the cache key on the client, where the sidebar counts, the open thread and the gathered tags/lists are keyed by revision, and the list pane is keyed by a separate listRevision that only new mail and user actions bump β€” tag changes do not

A notify watcher on the maildir debounces deliveries, runs notmuch new β€” so the post-new hook keeps doing its tag routing β€” and publishes the new revision. Mail arriving from a cron mbsync therefore appears in every connected client with no polling and no refresh button, and every view's list refreshes on its own. Tag changes are not this: marking a message read, whether by the reader or by another client, bumps revision only, so the sidebar counts and the open thread refresh but the list pane is not re-fetched and reshuffled β€” a list being read is not reordered because a message's tags changed. A message physically removed from the maildir fires mail:changed, which does refresh the list. The sidebar counts and the open thread refresh everywhere regardless, so the inbox badge still rises the moment mail lands.

Reading a message is a tag change, but it does not look like one on disk: notmuch synchronises maildir flags, so dropping unread renames the file and the watcher sees that rename as a delivery. The server therefore remembers what its own tag writes leave the database at (AppState::note_own_write) and the watcher stays quiet when index_new() returns exactly that revision β€” notmuch new moving nowhere is the proof nothing was delivered. Another client's write, or a message removed from the maildir, still moves it and is still announced. The list keeps rows through whatever does arrive β€” see held rows below β€” so what disappears from a list is what the reader asked to have written, never a side-effect of looking at it.

πŸ”—The mail index

Every read used to be a notmuch process β€” around 200ms for a page of a 23k inbox, which is a wall between a keystroke and a result. ecr-store keeps a SQLite mirror at ~/.local/state/ecr/index.sqlite3 and answers searches and counts from it.

It is a cache and never a source of truth. notmuch remains the only writer of mail state; the file can be deleted at any moment and is rebuilt on the next refresh. It carries message metadata β€” id, thread, timestamp, subject, sender, tags β€” and no words at all, which is why a 46k-message maildir costs about 9MB.

Only queries it can prove it answers identically are taken. index/plan.rs translates tag:/is:, id:, thread:, * and boolean combinations of those β€” which is every mailbox in the sidebar and every count beside it. Everything else is declined and the request goes to notmuch, as is any failure at all: a corrupt file, a poisoned lock, a SQL error. A translation that is merely close would be worse than none, because the index answers silently and a query it gets subtly wrong is a wrong list with nothing on screen to say so.

Text search is notmuch's, deliberately. An FTS5 index over the headers is easy to build and answers subject:invoice in half the time β€” with a different set of messages, because notmuch generates terms through Xapian with its own stemmer and word splitting and FTS5 reproduces none of it. Measured against a real maildir the totals differed on half the header queries tried. Being twice as fast about the wrong mail is not what the index is for.

Agreement is not assumed anywhere. crates/ecr-store/tests/index.rs runs every claimed query both ways against one database and compares field by field, and the same comparison against a real 46k maildir is what settled the fields a fixture cannot: that a thread's subject is its newest matched message's with one Re: removed, and that the author list has to go through notmuch's own lossy a, b| c rendering so that both paths split Anthropic, PBC the same wrong way.

Freshness rides on the same Revision as everything else. lastmod:a..b names exactly the messages a refresh has to re-read, so catching up is bounded by what changed rather than by the size of the database. Each chunk lands with the watermark it covers, so an interrupted refresh resumes.

A watermark cannot say whether the index is right, only how far it has got, and that distinction is load-bearing. lastmod: names what changed. It names nothing for a message that was deleted β€” the message is simply gone β€” and nothing for a message a refresh failed to write. Either way the index goes on claiming notmuch's exact uuid and lastmod, so every refresh after that starts at lastmod + 1, finds nothing to do, and the wrong contents answer every read for as long as the file exists. An index was found in precisely that state: 826 messages short, 169 messages notmuch had dropped still in it, at notmuch's exact revision. Deleted mail went on showing; worse, a thread carrying one of those stale rows could be neither marked read nor deleted, because the row's unread was beyond the reach of any write and its id β€” newest in its thread β€” was the one the client named in the tag operation, which notmuch tag --batch matched against nothing and exited 0 on.

So every refresh ends with an audit, a second and independent question asked of a database that is standing still: index/sync.rs's audit. Comparing the message count against notmuch count --exclude=false costs one process and catches anything that changed how many messages there are, which is every delivery and every deletion; ecr serve additionally compares the id sets outright at startup, which is the only check that sees a missed write and a deletion cancelling out in the count. Any disagreement rebuilds. A refresh that cannot rebuild β€” one running inside a read β€” condemns the index instead: it answers nothing at all from then on, every read goes to notmuch, and a task behind the server rebuilds it within the minute. What is at stake while that happens is speed, never correctness.

A read trusts the index for two seconds before asking notmuch whether the database has moved β€” and asks for the message count in the same process, since notmuch count answers both and the revision alone is what could not see any of the above. Every writer ecr knows about says so directly: its own tag writes and syncs invalidate immediately, and the watcher refreshes the index before publishing mail:changed, because the clients that event wakes ask for the new page at once and an index that has not caught up would answer the old one. The two-second window is what bounds how long a stranger's notmuch tag can go unnoticed, at one cheap process per window rather than one per request.

A first build reads the whole database β€” about 80 seconds for 46k messages β€” so it runs beside the server rather than before it. ecr serve starts listening immediately and reads fall through to notmuch until it is ready, which is what they did before the index existed. A read never rebuilds for the same reason: a rebuild costs far more than the notmuch call it would save.

index = false in server.toml turns it off, and ecr doctor reports its size, how far behind it is, and whether it holds a different number of messages than notmuch does. Only the last of those is worth acting on, and restarting ecr serve is the whole of the action: the rest is not a failure, because without an index every read is slower and every answer is the same.

πŸ”—Message content

Bodies do not come from notmuch show --format=json. That path did no charset or transfer-encoding decoding and could not represent inline images. Instead the store resolves the message file with notmuch search --output=files and parses it with mail-parser: full RFC 5322 + MIME, charset decoding, multipart/alternative nested in multipart/related, attachments and cid: inline parts.

HTML is sanitized server-side with ammonia: scripts and event handlers are removed, cid: references are rewritten to /api/v1/messages/{id}/parts/{n}, and remote images are stripped and counted unless explicitly allowed. w3m is no longer a dependency.

The plain-text view is the markup read as text, not the text/plain part. crates/ecr-store/src/markdown.rs converts the HTML to Markdown, and the text/plain half of a multipart/alternative is only the fallback. It used to be the first choice, and neither of the two things it can be was a reading of the message: on a message with no text part it is mail-parser's flattening of the markup, which runs block elements together β€” <div>one</div><div>two</div> arrives as onetwo β€” and on one that has a text part it is usually whatever generated the HTML saying the message cannot be displayed, with a URL. Headings, emphasis, lists, quotes and links survive as the punctuation they were always written as; script, style and svg are dropped. It is around 5ms for a 28KB message and is computed once per cached parse, so the reader waits for it on the first read of a message and never again.

The client renders those marks rather than printing them. Markdown is legible unrendered, up to the point where the punctuation stands in for something rather than decorating it β€” Rich **HTML** body is not a sentence anyone wants to read, ![](https://…) is not a way to read a picture, and [the notes](https://…/a/very/long/path) is a sentence with a URL wedged into the middle of it. So web/src/ui/markdown.ts renders emphasis, code spans, headings, list markers, quotes, rules, fenced blocks, images and links.

It stays a flat text view all the same. Every rule it emits is inline-level and the pane keeps the source's own newlines, so a line is still a line β€” which is what lets view mode's cursor move through it by one β€” and nothing changes the monospaced family. What it handles is exactly what the converter emits and nothing more: there is no strikethrough, because htmd drops <s>, and no tables, because it writes each cell as its own paragraph.

Which means the text path asks the same questions the HTML path does, out of the same context: cid: is resolved to a part URL, one naming no part is dropped, and remote images are kept or dropped and counted according to the reader's setting. None of the rest is a security boundary β€” it is inserted as text β€” with the single exception of an image's src and a link's href, which are attributes, and which are checked on both sides.

Remote images load by default. Turning load_remote_images off is what stops a sender learning that a message was opened; i then loads them one message at a time.

The client renders the result in an <iframe sandbox srcdoc>, so the sanitizer has a second layer beneath it. allow-scripts is the flag that matters and is never granted, which is what makes the frame inert; allow-same-origin is granted, because without it the parent cannot measure the document and every message renders at a fixed height, truncated β€” and it is what lets the reading cursor paint inside the frame without any script running there. Because a sandboxed frame cannot send an Authorization header and a root-relative URL would resolve against the web origin, the client absolutizes part URLs and appends the token before handing the HTML to the frame.

OpenPGP is the exception to all of that decoding: a signature covers the bytes that were transmitted, so pgp::detect reads the raw file and never a parsed message. It has a second half that is easy to miss β€” mbsync stores a maildir with bare newlines, and a detached signature covers the CRLF form, so verifying what is on disk reports every signed message as altered. The canonical form is checked first, with the stored bytes tried when it is not good, so a signer who signed the LF form is still believed.

πŸ”—Writes

All notmuch writes serialize behind a mutex β€” Xapian is single-writer.

Tag operations are validated and percent-encoded in ecr-store before they are written, because notmuch tag --batch exits 0 on malformed input. It silently ignores bad lines. A tag containing a newline would otherwise forge an extra batch line; a test pins notmuch's actual behaviour so the reason this validation exists stays visible.

The whole mark queue executes as one notmuch tag --batch invocation, not one subprocess per message. The queue is keyed by the message a thread row stands for, which notmuch reports in the search field query[0] β€” a query naming every matched message (id:a@x id:b@x), not a single id. Reading that string as an id produced a line matching nothing, and the exit-0 behaviour above meant tagging any multi-message thread failed in silence.

A draft carries its attachments base64 in the same request that sends it, so there is no staging directory and no second failure mode; ecr-core refuses a total over 25MB, and the send route alone raises axum's body limit to fit it.

πŸ”—Auth

Device tokens are 256-bit random values, stored only as SHA-256 digests and compared in constant time. Argon2 is deliberately not used: these are high-entropy tokens rather than passwords, so a slow KDF would add per-request cost without adding security.

The store is held in memory but re-read when the file moves, because the thing that writes it is another process: ecr token new writes tokens.toml and exits, and the running server is never told. Read once at startup, it refused the token that command had just printed β€” reported by the client as the server rejecting a valid token, fixable only by restarting a server nobody suspected. AppState::refresh_tokens compares the file's mtime and size, so a request pays one stat and reads only on a change, and it runs ahead of the "does this server require a token at all" question: an empty store means an unauthenticated API, so issuing the first token has to start requiring one at once rather than at the next restart. A failed read keeps what is already loaded β€” the file is truncated before it is rewritten, and reading a partial one as an empty store would disable authentication exactly while a token is being issued.

CORS defaults to allowing any origin. The API authenticates with a bearer token and never uses cookies, so the Origin header is not a security boundary β€” a hardcoded allowlist would break real deployments (a tailnet hostname, a phone, a different port) while stopping nothing, since a non-browser client ignores CORS entirely. --allowed-origin restricts it where that is wanted.

A refusal is raised from the one place every request passes through β€” Api's request, which calls the store's onUnauthorized on a 401 β€” rather than from whoever asked. Most callers swallow their errors to keep a pane quiet, so any other arrangement leaves a refused device staring at an empty client that explains nothing. The store keeps the refusal (needsToken) apart from whether the prompt is showing (askingToken): dismissing the prompt authorises nothing, and folding the two together made the thread list claim it could not reach a server that had answered. Pairing checks the token against /api/v1/revision before storing it β€” /api/v1/health is public, so it answers the same for a token that is worthless β€” and every resource keys on the token as well as the base URL, which is what makes a device paired mid-session refetch rather than stay empty behind the prompt that just fixed it. See pairing a browser.

πŸ”—Client

  • Layout. Rows are a CSS grid with a fixed date track and a min-width: 0 subject cell that ellipsizes. Nothing computes a width, which is why the author/date collision from the egui client cannot recur.

  • A row is one line, and the line is the subject. Every message in a mailbox is addressed to the reader, so the sender was the one line that could go without losing what a row is for β€” and the From display name is not reliably a person: notification senders put the actor's name there, which on a CI mailbox is the reader's own name, on every row. What is left is the subject, the thread count, the attachment marker and the date. The pitch follows the card rather than the other way round: 38px beside a pointer, 50px under a thumb, because a row carries touch-target and min-height would otherwise win over the inline height and leave the scroller counting in a number nothing was drawn at.

  • The account chip is the switcher's key. Where a view mixes accounts β€” All inboxes, a cross-account query β€” each row carries the letter that switches to its account, from the same accountKeys table ]a uses, so it is never a second alphabet to learn. An account id is a notmuch tag, so which account a row belongs to is a set intersection rather than a request. Inside one account the chip is not drawn at all: it would be the same letter on every row, and an empty grid track still costs its gap.

  • Windowing. windowRange() is pure arithmetic over (count, scrollTop, viewportHeight, rowHeight). It is hand-rolled rather than taken from a library because the library bound its scroll element at mount, and the container only exists after data arrives β€” so it rendered nothing.

  • Headings, and why the thread list counts differently. A uniform pitch cannot survive a separator: index * pitch is wrong by one heading's height for every heading above it, compounding until the list scrolls to the wrong thread. So the thread list uses offsetsOf/entryAt/windowSlice β€” a prefix sum over each entry's height, binary-searched β€” while the sidebar, which has no headings, keeps windowRange. Headings group contiguous runs rather than unique periods, because the list is in the order the query answered and gathering a month together would reorder the mail to suit the furniture. The heading pinned to the top edge is drawn over the scroller rather than made sticky: the rendered slab is transformed, and a transform is the containing block for everything inside it.

  • A heading's granularity, and the row that completes it. Only Today and Yesterday get one per day; the rest of the year groups by month and older mail by year, so a heading marks where the mail changes era rather than arriving every third row. Each group carries a Span, and its rows print what it does not β€” the clock under Today, the weekday and day under August, the day and month under 2025. That narrows the adaptive format only: the other four are explicit choices and are printed in full.

  • The date track is measured. The widest date on the page is picked by character count, which is exact in a monospaced cell, and turned into pixels by rendering one hidden copy of the real date cell and measuring it β€” again after document.fonts.ready, since a width taken before the webfont lands is wrong for the life of the page. A fixed column sized for 01 Apr 14:30 spent seven characters on nothing when the page was all 22:03; with the headings carrying the date's coarse half it settles at about 56px on a real inbox.

  • Keymap. A pure module with an explicit mode state machine. One rule prevents the stuck-mode class of bugs: while a text field holds focus, only Escape and Ctrl chords are ours β€” and of the chords, only the ones that move between panes, the pinned split or the conversation. C-u and C-e are how a shell user rubs out a line, and while the app claimed them the message behind an open composer scrolled instead. Those four chords are otherwise global β€” they scroll whichever pane has focus, by that pane's own idea of a line, and never move the cursor. A count may be typed before a key (4j); the engine carries it and App.tsx decides which actions repeat, because repeating one that toggles stages nothing at all. Transient overlays claim Escape before the keymap sees it: help closes, a visual range is abandoned, and with no range on screen Escape clears what Space picked and what is staged. The keymap itself reports idle-normal Escape as ignored, so those clearances live in App.tsx ahead of keymap.handle.

  • Vim. keymap/motions.ts holds the motions and text objects as functions of (text, caret) and nothing else; keymap/vim.ts is the state machine over them β€” visual mode, operators, registers, ., in-buffer search, C-c chords. Keeping motions separate is what lets a read-only surface reuse the whole grammar: ui/doc-cursor.ts flattens rendered DOM to a string and runs the same functions, so reading a message and editing a draft share one vocabulary. A keystroke that would change the buffer is refused there rather than applied.

  • The block cursor. A textarea has no caret shape and shows one selection, so normal and visual mode are painted by a mirror layer beneath a transparent textarea (ui/overlay.ts splits the buffer into runs). Insert mode hands rendering back to the textarea, where the native caret is the line cursor and composition works. Watch the cascade here: the bare textarea rule in styles.css is unlayered and outranks Tailwind utilities.

  • Reading with a cursor. The message frame is sandboxed without allow-scripts and stays that way. The parent walks contentDocument, which allow-same-origin permits and the resize measurement already relies on, and paints the cursor with the frame document's own selection. Resolve that document lazily: Solid builds nodes from a <template>, whose contents belong to an inert document with no selection until they are inserted.

  • Data. Resources keyed by revision (sidebar counts, open thread, gathered tags/lists) and listRevision (the list pane). New mail β€” an SSE mail:changed or sync:finished β€” and user actions go through bumpRevision, which bumps both, so every view refreshes. Tag changes (tags:changed, or marking a message read after it has been on screen) go through bumpForTagChange, which bumps revision only: the sidebar counts and the open thread refresh, but the list pane is not re-fetched and reshuffled β€” a list being read is not reordered because a message's tags changed. A message physically removed from the maildir fires mail:changed, which does refresh the list.

  • A row is a conversation, so a row action writes the conversation. The staging queue in state/store/marks.ts is keyed by thread and markToOps emits { target: { thread } }, which the server turns into one -- thread:"…" batch line. Keying it on the thread's newest message meant d deleted one message of a conversation and left the rest in the inbox β€” and since notmuch reports a thread's tags as the union over its messages, the row came back looking exactly as before, which reads as the key having done nothing. Auto-marking on read is the deliberate exception and still names the message: what has been read is the one that was on screen.

  • Held rows. A refetch is not allowed to take a row out from under the reader. When a message is auto-marked read, the store keeps its row β€” index and all, with unread stripped unless another message in the thread still carries it β€” and mergeHeld puts it back into any page that no longer carries it. The rows are held against the query they were read in, so changing view drops them; sync() and executeMarks() release them outright. The rule the user sees: a row leaves the list when they refresh, change view, or write staged tags with x, and at no other time.

  • Settings. One commented TOML file at ~/.config/ecr/settings.toml, held by the server so browser, desktop and phone read the same one. The client generates it from the tables in state/settings.ts, so every option reaches the file with its explanation and default, and the everyday sections sit above an ADVANCED divider. The file is edited, never regenerated: withValue replaces a single value in place so a switch on the settings page leaves the user's own comments and ordering intact. The server writes it only if it parses, so no client can leave behind a file no client can read.

  • Where a failure is reported decides what it means. The client has two channels and they are not interchangeable. lastError is the reason the thread list is empty: it is painted in one place, under cannot reach the server, beside the base URL and a retry, and the threads resource wipes it the moment the server answers. settingsProblem is the status bar β€” a standing complaint about a file, which survives every refresh because it is still true until someone edits that file. A bad line in settings.toml and a broken theme link both belong to the second, and a theme is complained about only when the server actually answered: a request that never arrived says nothing about the palette, and the empty list already reports the outage. Writing a theme failure into lastError made an outage read as a broken palette β€” the theme message displaced the real HTTP error whenever both requests failed, naming a file that was perfectly fine. Because the two complaints share one slot, a theme that loads retracts only the message the theme itself wrote.

  • The sidebar is one account. A box at the top names whose mail is below and opens the account switcher; the rows under it are that account's mailboxes and sections, and nothing else. Every account being a foldable group meant j from the top of the pane landed on another account's name rather than on any mail. The unified inbox is not lost with the All inboxes row: ALL_ACCOUNTS is a group like any other, so it is what the box shows when the switcher's 0 is picked. Eight letters jump straight to a row β€” i/s/d/f/a for the mailboxes, t/m/q for the sections β€” and they are pane-scoped, which is what lets s be Sent here while staying sync everywhere else. A view is loaded as well as pointed at; a section only opens, and pressing its letter again does not close it, because that is Tab.

  • The phone. A narrow screen shows one pane at a time, and which one it shows is store.pane() β€” the same three names the desktop moves between with h/l, so there is no second notion of where you are to drift. A ☰ in the top bar reaches the sidebar, because a phone has no h; without it views, tags, lists and account switching could only be had by typing a notmuch query by hand. Picking a view there hands over to the list, since on a phone the sidebar is the screen and a choice that changed nothing visible reads as a dead control.

  • Three panes are the widest of three answers, not the only one. store.layout() says which β€” ui/narrow.ts's layoutFor is the whole rule, a pure function of the window's width and this device's sidebar_min_width. Below that width the sidebar leaves the grid and is laid over the list as a drawer, so the list and the thread keep the space they had rather than being squeezed into three columns none of which is comfortable. The ☰ appears at every width the sidebar is not, which is why it is drawn from layout() rather than from the md:hidden it used to be: the line is a setting, and a breakpoint compiled into a class cannot follow one.

    Which pane the drawer is showing for is still pane(), not a second signal β€” the sidebar is up exactly while it has focus, so h, the ☰ and the scrim are three ways to say the same thing and none of them can disagree. Taking it out of the flow is what leaves the thread where it was: changing mailbox never costs the message being read.

    The phone's own line stays fixed at md. That breakpoint also decides the action bar, the plain-text composer, the swipe gestures and the safe-area insets β€” those answer is this a touch phone, not how many columns fit β€” so a setting that moved one without the others would leave a stacked client with a desktop's composer in it.

  • Touch is a first-class way in, not a degraded keyboard. The keymap engine is untouched β€” a Bluetooth keyboard drives a phone exactly as it drives a desktop β€” but below md the client offers its own vocabulary: the status line becomes an action bar of the current pane's actions, a row answers swipe (left archives, right flags) and long-press (selection mode, the equivalent of Space), and compose is a button. ui/row-gesture.ts holds the gesture arithmetic as pure functions of (dx, dy), for the same reason the vim motions are pure: a threshold decided inside a handler can only be checked by hand on a real phone. Vertical wins ties, because a list is scrolled far more often than a row is swiped. A long press that turns into a swipe yields to the swipe β€” touch events arrive in batches, so the hold timer can fire before the movement that disproves it.

  • The composer is a plain textarea below md (ui/PlainEditor.tsx). The vim editor is the point of the client on a desktop and stays there; on a phone there is no way out of normal mode, and routing keystrokes through the state machine costs autocorrect, swipe typing and the selection handles. Anything that names a key is hidden below md: a hint you cannot act on is worse than no hint.

  • Touch reached the store by a different path than keys did. Every pane-changing action existed only on the keymap: a tap on a sidebar row selected the view but stayed on the sidebar, and a tap on a thread opened it into a pane the phone was not showing. Both now do what their key does, and both stop the click, because the container beneath each one claims focus for its own pane and would otherwise undo the move. None of this is visible on a desktop, where all three panes are on screen and the clobber changes nothing.

  • The pane wrappers carry min-w-0. The desktop grid bounds each track with minmax(0, …); the single implicit column below md has no such bound, so one wide message stretched it past the viewport and every line of the body ran off the right edge β€” with the top bar, sized independently, still looking correct.

  • Android's back gesture is handed to the webview's history β€” WryActivity calls goBack() while canGoBack() and closes the app when it cannot β€” and a single-page client has none, so back quit from inside a thread. The panes are a stack (the list, with the sidebar or a thread over it), so one pushed entry describes it: leaving the list pushes, returning to it pops, popstate goes back to the list.

  • Safe areas. MainActivity calls enableEdgeToEdge(), and from targetSdk 36 there is no opting out, so the status and gesture bars are drawn over the app. Only the chrome that touches an edge pays an inset. The insets reach the CSS through --safe-* variables that default to env(safe-area-inset-*) rather than through env() at each use: a headless browser cannot be given a cutout, so the screenshot suite sets those variables instead and the one layout that exists for the phone is the one thing a test could otherwise never render.