A TUI reader for standard.site — long-form
writing published to the AT Protocol (Leaflet, Pckt, Offprint, GreenGale, and any
blog that publishes site.standard.* records). Sign in with your atproto account,
pull your subscriptions, and read — with images and real formatting, online or off.
Status: 1.0. Add a blog by handle, browse the sidebar → document list → reader, and read a block-flow with inline + cover images, search, a command palette, and full layout/theme customization — all over an offline cache. Reading needs no auth; signing in mirrors your follow-list to atproto subscriptions. No build step beyond
cargo, no runtime services. (RSS support is a later goal.)
- Read offline, with real images. A block-flow reader renders inline + cover images
(
ratatui-image; iTerm2 graphics where available, halfblocks elsewhere) over aredbcache, so anything you've opened reads with no network. - Fetches like an RSS reader. Following a blog doesn't backfill its whole history — opening a
feed pulls a bounded recent window (older posts on demand with
↓), so adding a prolific author with many blogs stays snappy. Open posts render instantly from cache, then freshen in the background if the author edited them. Unread counts sit beside each feed, with a dot on every unread post. - Six content decoders + a plaintext fallback — Markdown/markpub, Leaflet, Pckt,
Offprint, WordPress HTML, and Unthread all map to one neutral
RichDoc; unknown content degrades to typesettextContentrather than failing. - Full-text search across the cache (a hand-rolled inverted index over the spec's
textContentfield). - Make it yours. Cycle layouts (one / two / three-pane, or a drill-down) and resize
the sidebar; pick a built-in colour theme or hand-tune one in an in-app RGB editor; and
override layout or theme per blog. A first-launch picker sets your defaults; everything
persists to a human-editable
prefs.toml. - Keyboard-first, with a command palette,
?help, vim/arrow navigation, and mouse. In-post hyperlinks are navigable too — cycle them withn/Nand open withEnter, or click them straight from the rendered text. - A local follow-list, no account required — add a blog by handle, DID, or URL and it
persists; a handle that publishes several blogs lets you pick which to follow. Sign in (OAuth)
and it mirrors to atproto
site.standard.graph.subscription, reconciling local-only follows without silent deletes. - Graceful images — JPEG/PNG/GIF/WebP decode directly from the PDS; formats the default-features build can't decode (notably AVIF, which GreenGale emits) fall back to the Bluesky CDN's transcode-to-JPEG, then cache offline.
- A portable core. The engine has zero platform dependencies — a PS Vita frontend is a stated future goal, reusing all of it.
Prebuilt binaries (no toolchain needed) — grab the archive for your OS from the
latest release, unpack, and put
sr on your PATH. Builds are published for Linux (x86_64 + aarch64), macOS (Apple Silicon),
and Windows (x86_64); a SHA256SUMS is attached to verify them. The Linux binaries are static
musl, so they run on any distro regardless of glibc version. On macOS the binary is unsigned —
clear the quarantine flag with xattr -dr com.apple.quarantine sr if Gatekeeper blocks it.
From source (needs Rust 1.88+):
cargo install --git https://github.com/koalabuttz/standard-reader
cargo run -p standard-reader # run from a clone (binary: sr)
sr launch the interactive reader
sr fetch <handle|did> (debug) fetch + decode + cache, print to stdout
sr cached (debug) render the local cache, no network
Keys in the reader:
| key | action | key | action |
|---|---|---|---|
a |
add a feed (handle / DID / URL) | Enter |
open feed / post / focused link |
/ |
search across feeds | Tab / Esc |
cycle / step back pane focus |
: / Ctrl-P |
command palette | j/k, ↑/↓ |
move selection / scroll |
r |
refresh the selected feed | g / G |
top / bottom |
d |
unfollow the selected feed | PgUp/PgDn |
scroll ±10 |
m |
mark the open post read | o |
open the post in a browser |
n / N |
focus next / prev link | click | open link / select a row |
t |
theme (presets + RGB editor) | \ |
cycle layout (1/2/3-pane, drill-down) |
b |
customize this blog | < / > |
narrow / widen the focused pane |
i |
toggle images (text-only) | L |
sign in / out (atproto OAuth) |
? |
help | q |
quit |
crates/
standard-core/ lib · ZERO platform deps — the whole brain (synchronous)
model · the RichDoc AST + Document/Publication/Subscription
decode · ContentDecoder trait + per-publisher decoders
atp · AT-URI parsing + XRPC request building (over a Transport)
store · the Store cache trait
search · inverted index over textContent
standard-tui/ bin `sr` · ratatui + reqwest + redb + OAuth (the impls)
The core is synchronous and I/O-agnostic. Two traits are the only seam a new platform must cross:
atp::Transport— perform an XRPC GET/POST (and attach auth). Desktop:reqwest. A PS Vita port: the Vita's net stack.store::Store— the offline cache (docs, read-state, blobs, sync cursors). Desktop:redb. Elsewhere: whatever fits.
So the hard part — atproto reads, content decoding, caching, search — is written
once and reused; a different platform reimplements only transport, storage, and
drawing the RichDoc. The desktop frontend keeps fetches non-blocking by running core
operations on a worker thread and channeling results into the ratatui render loop.
site.standard.document.content is an open union; each publisher embeds its own
lexicon. textContent is flat plaintext (a fallback only). Decoders dispatch on
content.$type and map them all to one RichDoc:
content.$type |
shape | decoder |
|---|---|---|
(bare string) / at.markpub.markdown |
Markdown (GreenGale body, Sequoia, markpub) | Markdown |
pub.leaflet.content |
pages[].blocks[].block + byte-range facets |
Leaflet |
blog.pckt.content |
items: [blog.pckt.block.*] + facets |
Pckt |
app.offprint.content |
blocks + byte-range facets | Offprint |
org.wordpress.html |
{ html } — rendered HTML |
Wordpress |
at.unthread.content |
{ content } — a Markdown string |
Unthread |
*#contentRef |
reference to another record (GreenGale) | two-phase |
| (unknown / absent) | typeset textContent |
Plaintext |
Leaflet/Pckt/Offprint share one byte-range facet engine. GreenGale is two-phase:
the #contentRef names an AT-URI the frontend fetches and re-decodes (the same idea, at
block granularity, resolves Pckt galleries). Adding a platform is one new ContentDecoder
plus one line in the registry; decoders are pure and never panic on partial input.
Styling is the reader's own, and yours to customize: pick a layout (one/two/three-pane or a drill-down, with independently resizable panes), pick or hand-edit a colour theme (built-in presets plus an in-app RGB editor), and override either per blog — all decode the same structure, so customization only changes presentation.
cargo build
cargo test # the full suite (124 tests across both crates)
cargo test -p standard-core # just the engine
cargo run -p standard-reader # runs the `sr` binary
client_metadata.json is the atproto OAuth client_id, served at
https://www.davidlewis.xyz/standard-reader/client_metadata.json — the canonical www
host, because the apex 301-redirects and a client_id must not redirect. (A reference copy
lives at this repo's root; the served copy is in the website repo.) Login uses the loopback
redirect to http://127.0.0.1:4599/callback (DPoP/PKCE/PAR via atrium-oauth).
The session (DPoP key + tokens) is stored in the OS keyring where a native backend exists —
macOS Keychain, Windows Credential Manager — falling back to a 0600 file under XDG config
elsewhere (Linux by default, and anywhere without a secret store, e.g. headless or Crostini; on
Windows the file fallback relies on per-user-profile ACLs, not 0600). Linux Secret Service is
opt-in at build time via --features secret-service (it pulls libdbus, a C dep, so the
prebuilt binaries don't). The non-secret account sidecar (handle + DID) stays a plain file. Set
SR_OAUTH_LOCALHOST=1 to fall back to the no-hosting dev client (local work, or before the
metadata is deployed). Reading public feeds needs no auth — sign-in only enables write/sync of
subscriptions.
GNU GPL v3 (or later).