18 KiB
The Mews
Project layout
app/— Flutter application (main codebase)mews-spec/— Mews Profile definition filesnix-android-sdk/— Nix flake providing the Android SDK for devbox
Flutter version: 3.41.9-sdk-links (keep in sync with devbox.json).
Product spec
What it is
A Flutter browser/client for Mews pages (see mews-spec), Gemini capsules and Spartan sites.
Approach
Local-first: all data is stored locally in a Drift database. Network operations are secondary and never block local functionality.
Posts found on a subscribed page are stored per site with a read mark, so an unread post survives a restart. A post's address identifies it for its lifetime (spec 6.1), so finding it again never clears its mark. Subscribing to a site stores what it already published as read, so a new subscription arrives without a backlog.
Protocols
The client speaks gemini:// and spartan:// alongside the web. A page over either is gemtext, which converts into the same document model a Mews page parses into, so it renders through the same reader presentation, joins the history and the swipes, and can be bookmarked and subscribed to; a link label that begins with a date is a dated entry, so subscriptions work on capsules too. A body's MIME type decides what becomes of it: text/gemini is converted, other text is shown plainly as preformatted text, an image is shown in place, and anything else is refused rather than shown as garbage.
Gemini runs over TLS on port 1965; Spartan runs over plain TCP on port 300 with a request line of host, path and body length. Redirects are followed up to five times and only within the same protocol; a redirect pointing elsewhere is left for the reader to open as their own navigation. Neither protocol has validators, so a cached page is only the offline fallback, never a revalidation. An input request (Gemini 1x) is rendered as a one-field form whose answer rides back as the address's query string; a sensitive input (11) asks quietly.
Servers present self-signed certificates, so the client decides what to trust itself: the first visit to a host pins the certificate it saw, and every later visit compares against that pin. A change while the pinned certificate has expired is the routine renewal and is accepted silently; a change while it is still valid is refused with an error page offering the reader the choice to trust the new certificate. Trust is per host and port.
A client certificate is an identity. Certificates are imported from PEM files, one file holding both the certificate and its key or two holding one each, and are named after their file. They are managed on mews:certificates, linked from the settings page; removing one takes a second step, because the sites it is worn at lose it too. A certificate is bound to one host at a time and worn only there, and it is presented only when the site asks (Gemini 60): the answer is a picker naming each identity, and the choice is remembered for the next visit. A site that refuses the certificate presented (61) or calls it invalid (62) gets an error page explaining so, with the site's own words under it. Private keys live as files in the app's own storage, never in the database.
An address typed without a scheme is tried as https first, then on its wap. subdomain, then over gemini, then over spartan, before an error is shown; an address carrying a scheme is never second-guessed. When nothing answers, the first error is the one shown, beside the web address.
An image over a protocol has no address the platform could load, so its bytes are fetched through the same transport, wearing the same trust and identity as the page it appears on, and decoded in place; protocol images are never cached, so they arrive fresh on each visit. A missing image falls back to its alt text, the way a missing web image does.
Architecture directives
Keep files small and focused.
Layer separation
presentation/— rendering, input, gestures, navigation.application/actors/— isolate-backed actors for CPU/IO work (image processing, export, file I/O).application/controllers/— plain coordinators that own signals and handle synchronous UI commands.database/— Drift tables, DAOs, queries.- Signals expose UI-consumable state derived from the application layer.
- No business logic in widgets.
State management — Signals
- Use the
signalspackage for reactive UI state. - Widgets observe signals and dispatch actions to the application layer.
- Derived UI state should be computed, not manually synchronized.
- Do not duplicate state across multiple signals.
Business logic — Actor Model (actors package)
Use the actors package only where Dart Isolate isolation is genuinely useful: CPU-heavy work, blocking I/O, or tasks that must not block the UI thread. Do not use it for pure UI state or simple synchronous coordination.
Rules for all actors and coordinators:
- Model interactions as explicit sealed command types.
- No business logic in widgets — dispatch commands to the application layer.
- Keep classes small and focused; avoid large catch-all actors.
- Pass references explicitly when communication is needed.
Isolate constraint: The actors package runs handlers in separate Dart Isolates. Handlers cannot directly update signals on the main isolate. Isolate-backed actors must return results via message passing; the calling code applies results to signals.
Persistence — Drift
- Use
driftfor all tables, DAOs, and queries. - Keep database definitions in
database/separate from UI code.
v1 reader UI
The v1 client presents one reader screen:
- An address field pinned at the top, below the status bar inset on phones; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, since a page that fits the viewport leaves nothing to scroll for it. A short tap on the page shows a hidden bar and hides a shown one, unless the tap opened something on the page — a link, a form, a control — for those leave the bar where it was. On pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed.
- A web page that cannot be loaded offers to open in the reader's system web browser, since the web can be shown outside; a page over gemini or spartan has nowhere else to go, so its failures offer only trying again.
- A web page that is not a Mews page is not a broken page. The client reads pages as strict XML (spec 7.1) and the web is not well-formed XML, so such a page is named as the web page it is and offered two ways on: a simplified view, or the reader's own browser. The simplified view is the same markup read as far as the Mews subset reaches — elements Mews supports are shown, the rest are unwrapped and keep their content, and markup that is not content is left out. A form is the one exception to that: Mews has forms, but a web page's form is left out of the view, since the reader cannot usefully answer a search box or a sign-in here and submitting one would only land them on another page this client cannot show as Mews. Only the form goes; content it wrapped is kept. It renders through the same reader presentation as any page, so its links are followed in the app and fail back the same way, its text can be kept as a quotation, and it says it is simplified. Asking for it belongs to the address it was asked for, so a link from it that also fails explains itself rather than simplifying unasked. A page that carries the conformance marker and still will not parse is reported as the markup error it is: the marker, not the MIME type, is what claims a page as Mews, since Mews pages are served as
text/html. - A table cannot hold another (spec 4.2), so a nested one — the old web's way of laying out a page — is shown as a table of its own, after the table whose cell held it, rather than flattened into that cell's text. A row belongs only to the table that owns it, never to one it is nested inside. Rows of unequal length are filled out to the widest, since a table with holes cannot be drawn; a cell spanning several columns sits in the first of them.
- On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome.
- A D-pad drives the reader on any host, so a phone or remote without a touch screen reaches everything. The page takes the focus when it appears, and an error page puts it on its first button. On a page, Up and Down scroll by three lines, and Left and Right walk the links and form controls in reading order, starting from the first one on screen when nothing highlighted is in view. The center opens the highlighted link. Holding Left goes back, holding Right goes forward, and holding the center opens the hub; a press acts when the key is let go, and a hold acts when the key starts to repeat. In a text field the arrows move the cursor and leave the field at the edge of its text, and Up or Down returns to the page. On a touch host the Menu key shows the address bar with the focus in its field and hides it again with the focus back on the page; Down leaves the field for the page, and Left or Right at the edge of its text moves to the bar's buttons.
- A swipe right goes back and a swipe left goes forward through the history stack. On a pointer host the whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text, and the address bar's buttons work as well. On a touch device, where the column leaves no margin, the page surface itself accepts the swipe: a finger that crosses the slop sideways, before the long press that starts a text selection completes, is swiping, while a finger that held still that long keeps its drag for the selection.
- The client's own pages live under the
mews:scheme.mews:hubnames them and says what each one holds;mews:posts,mews:quotations,mews:bookmarks,mews:subscriptions,mews:historyandmews:settingseach carry a strip linking to the others. Baremews:is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription. mews:postsis every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read.- A site that publishes through an Atom feed instead of dated links is read through the feed (spec 6.2). Dated links are the normative mechanism, so the feed is only asked for when the page itself listed nothing, and a feed on another site is refused. Posts are keyed by address, so a post listed both ways is stored once, under the title the page gave it.
- Subscribed sites are checked once in the background at launch, and whenever the reader asks. Sites are checked one after another rather than at once, and a site that cannot be reached keeps the posts already held and is still recorded as checked, so one site being down never stops the rest.
- A fresh install comes with the project's directory of sites already bookmarked, so a first session has somewhere to go. It is a bookmark like any other: removing it is the reader's own, and no later start gives it back.
- The reader can tag bookmarks and sites. A tag exists only while something carries it, so adding a tag and making one are the same act;
mews:tagslists every tag in use and what carries it, and following a tag narrows the list it belongs to. There is no renaming and no empty tag. - The reader can keep marked text as a quotation. Marking text on a page offers, in the selection toolbar, to save it, and a short note confirms the save, since closing the toolbar looks the same either way; the kept text is stored with the page it came from, with the author when the page's metadata named one, and with the site's title when the reader's subscriptions know the site the page belongs to.
mews:quotationslists what is kept, one row per quotation naming the author, the article's title and the site it came from — by its own title when the reader's subscriptions know it, by its host otherwise — with the day it was kept, ordered by that day or by the page it came from, narrowed by a search over the text, the author, the page's title and its address. A row shares its quotation through the system share sheet, together with a citation shaped as APA names a web page: the author first when the page carried one, otherwise the article's title; the year as a no-date marker, since a page declares no date; then the article's title, the site's own title (its host when the subscriptions do not know it), and the address. A row can remove its quotation, and nothing else is offered, since the citation is the record of where the words came from. - Each library page carries a search box that narrows it by title or address as the reader types, a quarter second after they stop. The query rides in the address as any other view state does, but it replaces the step of the walk they are standing on rather than adding one, so a search of eight letters is one step back and not eight; the box keeps what they typed as the page rebuilds under them, and follows the page when the page sets it, as clearing the search does. Submitting the box still works, for a reader who would rather say when to search. Each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone.
- A change is a link too: following
mews:do/<verb>applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. - Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line.
- The interface ships in English, German, Spanish, French, Hebrew, Japanese, Portuguese and Russian. It follows the device's language by default and the reader can choose another on the settings page, where each language is named in its own words so it can be found from an interface they cannot read. A message a language has not translated falls back to English rather than disappearing.
- Hebrew mirrors the whole interface. Flutter's own widgets follow the same language, and our own pages take their direction from it; a fetched page keeps declaring its own, so an English interface still renders a Hebrew page right to left.
- Plurals are separate messages rather than a rule inside one, and the choice is made in code from the language's own rules: Russian separates two to four from the rest, Hebrew has a form for two, French and Portuguese count zero as singular, and Japanese has one form. A language needing a form English lacks names it with the same id and that suffix.
- Fonts are bundled, never fetched. The reader picks between two sans faces, Atkinson Hyperlegible and Luciole, and two serifs, Literata and Charis SIL; all four carry regular, italic, bold and bold italic, which the renderer needs for emphasis. The chosen face applies to the whole app, chrome included, so the address bar and the page below it are one face.
- Colors are the client's own, never the page's stylesheet: the five palettes of the default stylesheet in light and dark, or a palette derived from the address (spec 5.3). A derived palette is seeded by the host, and on a
/~useror/users/userpage by the user and host together, so everyone on a shared host gets their own color. The seed picks one of 24 hues, three background tint strengths and a tinted or plain foreground, and the lightness and chroma measured from the fixed palettes supply the rest: only the hue moves, so a site is recognizable by its color without any site coming out louder than the fixed palettes. Links reach the 7:1 contrast the spec asks for by moving their lightness until they meet it, so no hue needs a correction of its own. - A character the chosen face has no glyph for is looked for in that face's own fallbacks before the platform is asked. Hebrew always comes from a bundled Hebrew face matched to the shape of the chosen one, so it never depends on what the device has installed. The serifs also cover Greek, Cyrillic and a wide Latin range and fall back to each other; the two sans faces are narrow and have no bundled sans to turn to, so for those the platform answers, and it answers a sans with a sans. Scripts nothing bundled covers, such as Arabic and the CJK languages, always come from the platform.
- Adding a font is four TTFs in
app/assets/fonts/with its licence beside them, onefontsentry inapp/pubspec.yaml, one entry inapp/lib/application/fonts.dart, and one label inapp/locales/en-US/main.ftl. A family named in the registry but missing from pubspec would render in the platform's font without saying so, soapp/test/fonts_test.dartchecks that the two agree.