the-mews/SPEC.md

8.4 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).

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.

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; it hides when the reader scrolls down and returns when they scroll up or reach the top. 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 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.