the-mews/SPEC.md

12 KiB

The Mews

Project layout

  • app/ — Flutter application (main codebase)
  • mews-spec/ — Mews Profile definition files
  • nix-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 signals package 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 drift for 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; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, and 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.
  • 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 swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well.
  • The client's own pages live under the mews: scheme. mews:hub names them and says what each one holds; mews:posts, mews:bookmarks, mews:subscriptions, mews:history and mews:settings each carry a strip linking to the others. Bare mews: 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:posts is 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.
  • 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:tags lists 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.
  • Each library page carries a search box that narrows it by title or address, and 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.
  • 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, one fonts entry in app/pubspec.yaml, one entry in app/lib/application/fonts.dart, and one label in app/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, so app/test/fonts_test.dart checks that the two agree.