Adds a "Companion docs" header (README, technical, ADR, QFD, roadmap) and inline pointers from the auth section to ADR-005, the screen layout to ADR-003, and the acceptance criteria block to qfd §6 — so each "what must this do" claim has a one-click jump to its rationale or target.
typewriter
A distraction-free, hackable, DIY writing machine. ESP32-S3 + e-ink + a real mechanical keyboard. You write Markdown, you commit, you push. Nothing else runs on it.
Status: pre-MVP. Hardware not yet on bench. Bring-up in progress.
Vision
A single-purpose appliance that boots into a text editor with a Vim keymap, edits Markdown files, and (optionally) pushes them to a git remote (GitHub first) over Wi-Fi. No browser, no notifications, no apps. Open lid → write → push (or don't) → close lid.
Two file scopes coexist on the SD card:
- Tracked — lives in the git working copy, gets committed and pushed.
- Local — never leaves the device. Drafts, journal entries, scratch, things that aren't ready or aren't anyone else's business.
Same editor, same keymap; the difference is just whether Ctrl-G (commit &
push) is offered.
Hardware
| Part | Choice | Why |
|---|---|---|
| MCU | ESP32-S3-N16R8 (16 MB flash, 8 MB octal PSRAM) | USB OTG host (for the keyboard), Wi-Fi, BLE, dual core @ 240 MHz, plenty of PSRAM for git pack data and screen buffer. Best-supported Rust target in the ESP family. |
| Display | GDEY0579T93 + DESPI-c579 breakout (5.79", 792×272, 1-bit) | Good Display panel matched with its own FPC breakout. Strip aspect (~2.9:1) — Freewrite-coded: ~12 lines of edit area, ~95 cols. Tiny framebuffer (~27 KB) leaves PSRAM headroom. The DESPI-c579 is a passive level-shifter / FPC-to-header board, not an active controller — driven over plain SPI like any other epd. |
| Keyboard | Nuphy Air60/Halo65 wired USB-C | ESP32-S3 acts as USB host via TinyUSB. BLE-HID is a fallback but contends with Wi-Fi for radio time during push. |
| Storage | microSD over SPI | Holds both the git working copy (/sd/repo/) and the local-only scratch space (/sd/local/). Internal flash is for firmware + config only. |
| Power | USB-C wall power for MVP, 18650 + IP5306 in Phase 3 | Measure power profile on real hardware before sizing the battery. E-ink + sleep should give multi-day battery life but battery introduces charging, safety, and BMS complexity we don't need on day one. |
| Enclosure | 3D-printed, hinged lid | Phase 4 concern. |
Why the 5.79" strip aspect: less screen than a 7.5" page-shaped panel, but the long-narrow shape biases toward "current line + recent context" — the writing posture we actually want. The smaller framebuffer is cheap on RAM, and SPI panels keep the GPIO budget open for SD + future peripherals. A larger panel (10.3" via IT8951) stays on the table for v1.x once UX is proven.
Software stack
Language: Rust on esp-idf-rs (std). Decision is load-bearing — see the
rejected alternatives below, and docs/adr.md for the full
decision log covering language, UI strategy, display, git lib, auth,
concurrency, storage, power, and keyboard transport. How each decision is
weighted against the user-facing requirements — and the critical performance
budget that falls out — lives in docs/qfd.md.
| Layer | Crate / Component | Notes |
|---|---|---|
| HAL / runtime | esp-idf-svc, esp-idf-hal |
std build, gives us heap, threads, VFS, mbedtls, Wi-Fi stack. |
| Display | embedded-graphics + epd-waveshare (or custom driver) |
Pixel framebuffer with partial-refresh regions. We track dirty rects ourselves. The GDEY0579T93 uses an SSD1683-class controller; if it's not already in epd-waveshare, we write a small driver against embedded-hal SPI — ~300 LoC, low risk. |
| Editor core | Custom, in-tree | Rope buffer (ropey), mode state machine, Vim keymap table. |
| TUI-style layout | Custom thin layer (~500 LoC) | API inspired by Ratatui (Layout, Block, Paragraph) but renders directly to embedded-graphics. See below. |
| USB host | esp-idf TinyUSB bindings |
Boot-protocol HID is enough for the keyboard. |
| Git | gitoxide (gix) |
Pure-Rust, modular. We only need add / commit / push (smart HTTP). No libgit2, no mbedtls glue beyond what esp-idf already gives us. |
| TLS | mbedtls via esp-idf |
Used for GitHub HTTPS. ~120 KB heap during handshake — fits in PSRAM. |
| Auth | GitHub Personal Access Token in encrypted NVS | SSH on embedded is painful; HTTPS+PAT is the pragmatic path. |
| Filesystem | FAT on SD (esp_vfs_fat) |
Working copy lives here. Internal LittleFS holds config. |
Why not Ratatui
Ratatui assumes a character-grid terminal with an ANSI backend. E-ink is a pixel framebuffer with partial-refresh windows. The right primitive for e-ink is dirty-rectangle tracking aligned to the panel's refresh regions — Ratatui's per-cell diff model fights this. We can borrow its widget API shape (it's a good one) without dragging in the terminal abstraction. Net saving: probably 200 KB of binary and a lot of pretending the screen is a VT100.
Why not Gleam + Shore
BEAM doesn't run on ESP32. AtomVM does, but: memory budget is tight, Gleam-on- AtomVM is bleeding-edge, and there are no bindings for USB host / e-ink / SD / TLS / git in that ecosystem. Shore is also terminal-oriented, so the same impedance mismatch as Ratatui applies. Building this on Gleam would be a research project stacked on a research project. Revisit in 2-3 years.
Why not C / Arduino
Workable, well-trodden, fastest path to a blinking screen. But this is a project I want to keep evolving — Rust's refactoring leverage and type safety pay off the moment we start adding modes, palette, search, etc.
UX boundaries set by the medium
E-ink is a brutal honesty filter on UI choices. Hard constraints we design around, not against:
- No cursor blink. Kills the panel and the battery.
- Typing latency target: ≤ 200 ms from keypress to glyph on screen, using partial refresh on the affected line only.
- Full refresh every ~20 partials to clear ghosting. User-visible flash — schedule it on pauses (>1 s of no input).
- No smooth scrolling. Page-style jumps only.
- No animations. Anywhere.
- Render only changed lines, not the viewport.
Roadmap
Frequent releases. Each version is a usable artifact, not a checkpoint.
Macro-plan below; per-version scope lives in docs/roadmap.md.
gantt
title Typewriter — macro plan
dateFormat YYYY-MM-DD
axisFormat %b %Y
section MVP
v0.1 it writes, it pushes :v01, 2026-06-01, 4w
section Vim
v0.2 navigation :v02, after v01, 3w
v0.3 editing :v03, after v02, 3w
v0.4 visual + ex :v04, after v03, 2w
section Files
v0.5 palette + multi-file :v05, after v04, 3w
v0.6 markdown :v06, after v05, 2w
v0.7 search + git :v07, after v06, 3w
section Hardware polish
v0.8 battery + sleep :v08, after v07, 4w
v0.9 robustness :v09, after v08, 4w
v1.0 polish :v10, after v09, 4w
| Version | Theme | One-liner |
|---|---|---|
| v0.1 | MVP | Boots, edits one file, Ctrl-G pushes. |
| v0.2 | Vim nav | Normal/Insert, motions, line numbers. |
| v0.3 | Vim edit | dd yy p, undo/redo, counts. |
| v0.4 | Visual + ex | v V, :w :q :e, status line. |
| v0.5 | Files | Ctrl-P over /repo + /local, buffers. |
| v0.6 | Markdown | Headings, list continuation, soft-wrap. |
| v0.7 | Search + git | /, :Gpull, :Gbranch, commit msg. |
| v0.8 | Power | 18650 + sleep + lid switch. |
| v0.9 | Robustness | Crash-safe writes, reconnect, settings. |
| v1.0 | Polish | Boot ≤ 3 s, fonts, enclosure, guide. |
| v1.x | Stretch | 10.3" panel, spell-check, themes, BLE. |
Repo layout (planned)
/firmware Rust crate, esp-idf-rs target
(SD card mounted at runtime contains /repo and /local)
/src
editor/ rope buffer, modes, keymap
render/ embedded-graphics + dirty rects
git/ gitoxide wrapper, auth
usb/ TinyUSB host glue
fs/ SD + NVS
build.rs
sdkconfig.defaults
/hardware BOM, schematic, enclosure (later)
/docs ADRs, power measurements
package.json pnpm + oxfmt — formatting toolchain for docs/JSON
(companions: pnpm-lock.yaml, .oxfmtrc.json, .node-version)
Open questions / risks (tracked, not yet resolved)
gix-clone+gix-packsmart-HTTP push working onesp-idf-rswith mbedtls — needs an early spike before locking the stack.- TinyUSB host stability with arbitrary HID descriptors (Nuphy reports consumer-control keys we may need to ignore).
- Heap fragmentation over a long writing session with PSRAM allocator.
- Real-world e-ink ghosting with current partial-refresh cadence.
These get resolved by writing code, not by deciding harder.