πCrates
| Crate | Responsibility |
|---|---|
ecr-core | Wire types only β no I/O. Account, ThreadSummary, Message, PartMeta, TagOp, Query, Revision, Draft, Doctor. |
ecr-store | Everything that touches the mail: config discovery, the MailStore trait, the notmuch backend, the SQLite mail index, MIME parsing, sync and send. |
ecr-server | axum: REST, SSE, bearer auth, the maildir watcher. A library β it builds no binary. |
ecr-cli | The ecr binary. Owns the command surface and everything user-facing; deliberately free of GUI dependencies. |
shell | Tauri v2 desktop and Android wrapper around web/dist, built as ecr-desktop. |
web | SolidJS 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:
- an explicit path in
~/.config/ecr/server.toml - the tool's own environment variable (
NOTMUCH_CONFIG,MBSYNCRC) - the XDG location (
$XDG_CONFIG_HOME/notmuch/<profile>/config,.../isyncrc,.../msmtp/config) - 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 toselfβ 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. CreateisNear,ExpungeandRemoveareNone. 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
ETagon thread listings, soIf-None-Matchyields304 - 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 separatelistRevisionthat 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,  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: 0subject 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
Fromdisplay 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 carriestouch-targetandmin-heightwould 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
accountKeystable]auses, 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 * pitchis wrong by one heading's height for every heading above it, compounding until the list scrolls to the wrong thread. So the thread list usesoffsetsOf/entryAt/windowSliceβ a prefix sum over each entry's height, binary-searched β while the sidebar, which has no headings, keepswindowRange. 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 for01 Apr 14:30spent seven characters on nothing when the page was all22: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-uandC-eare 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 andApp.tsxdecides 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 whatSpacepicked and what is staged. The keymap itself reports idle-normal Escape as ignored, so those clearances live inApp.tsxahead ofkeymap.handle. -
Vim.
keymap/motions.tsholds the motions and text objects as functions of(text, caret)and nothing else;keymap/vim.tsis the state machine over them β visual mode, operators, registers,., in-buffer search,C-cchords. Keeping motions separate is what lets a read-only surface reuse the whole grammar:ui/doc-cursor.tsflattens 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.tssplits 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 baretextarearule instyles.cssis unlayered and outranks Tailwind utilities. -
Reading with a cursor. The message frame is sandboxed without
allow-scriptsand stays that way. The parent walkscontentDocument, whichallow-same-originpermits 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) andlistRevision(the list pane). New mail β an SSEmail:changedorsync:finishedβ and user actions go throughbumpRevision, which bumps both, so every view refreshes. Tag changes (tags:changed, or marking a message read after it has been on screen) go throughbumpForTagChange, which bumpsrevisiononly: 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 firesmail: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.tsis keyed by thread andmarkToOpsemits{ target: { thread } }, which the server turns into one-- thread:"β¦"batch line. Keying it on the thread's newest message meantddeleted 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
unreadstripped unless another message in the thread still carries it β andmergeHeldputs 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()andexecuteMarks()release them outright. The rule the user sees: a row leaves the list when they refresh, change view, or write staged tags withx, 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 instate/settings.ts, so every option reaches the file with its explanation and default, and the everyday sections sit above anADVANCEDdivider. The file is edited, never regenerated:withValuereplaces 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.
lastErroris 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.settingsProblemis 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 insettings.tomland a brokenthemelink 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 intolastErrormade 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
jfrom 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_ACCOUNTSis a group like any other, so it is what the box shows when the switcher's0is picked. Eight letters jump straight to a row βi/s/d/f/afor the mailboxes,t/m/qfor the sections β and they are pane-scoped, which is what letssbe Sent here while stayingsynceverywhere 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 isTab. -
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 withh/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 noh; 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'slayoutForis the whole rule, a pure function of the window's width and this device'ssidebar_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 fromlayout()rather than from themd:hiddenit 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, soh, 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
mdthe 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 ofSpace), and compose is a button.ui/row-gesture.tsholds 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 belowmd: 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 withminmax(0, β¦); the single implicit column belowmdhas 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 β
WryActivitycallsgoBack()whilecanGoBack()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,popstategoes back to the list. -
Safe areas.
MainActivitycallsenableEdgeToEdge(), 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 toenv(safe-area-inset-*)rather than throughenv()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.