docs: refresh v0.1 spec for gct publish flow and dev-only build-time config

Two coupled changes that emerged from one /deep-design session, both touching
the same v0.1 paragraphs:

(1) Align Ctrl-G with the user's existing gct shell alias: git add . ->
    short-circuit if nothing staged -> commit with an ISO-8601 timestamp (no
    wip prefix) -> push -> on push failure, git pull --no-edit then retry.
    Atomic from the user's view. Recorded as ADR-010. The previously-planned
    v0.7 commit-message-prompt item is removed; it contradicts the
    gct/timestamp model.

(2) Replace the v0.1 captive-portal first-run with build-time env-var
    config: build.rs reads TW_* env vars and embeds them as constants. No
    NVS read, no LittleFS mount, no AP mode, no HTTP server. The v0.1
    target user is the dev themselves; the first release usable by non-dev
    users is v0.9, and the v0.9 settings entry is reframed accordingly.
    ADR-005 updated to describe the build-time path and the v0.9 migration.

The two changes share files because the v0.1 spec is one interlocked
document; splitting further would require line-level surgery without
improving auditability.
This commit is contained in:
Julien Calixte
2026-05-14 20:40:09 +02:00
parent 668478aa40
commit 7055d01e9d
5 changed files with 153 additions and 82 deletions

View File

@@ -211,14 +211,17 @@ on-device, and reasonably secure for a personal appliance.
### Decision
**HTTPS + PAT.** Stored in internal LittleFS, encrypted with a key derived
from the chip's eFuse so a stolen SD card alone is not enough. Captive
portal accepts the PAT during first-run setup.
**HTTPS + PAT.** In v0.1 the PAT (and all other config) is compiled into the
firmware binary via build-time env vars — the dev's-only-user model makes the
binary-as-secret-store acceptable. From v0.9 onward, the PAT moves to
encrypted LittleFS with a key derived from the chip's eFuse, so a stolen SD
card alone is not enough.
### Consequences
- The user must generate a PAT with `repo` scope. Documented in
[v0.1 product → first-run flow](v0.1-mvp-product.md#first-run-provisioning-flow).
- The user (= dev, in v0.1) must generate a PAT with `repo` scope and supply
it as a build-time env var. Provisioning is build-time only — see
[v0.1 product → provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only).
- PAT is never logged. Validated in code review.
- Rotation in v0.1 = wipe NVS and re-run setup. Proper rotation UI is v0.9
— see [roadmap → v0.9](roadmap.md#v09--robustness--).
@@ -371,6 +374,54 @@ if TinyUSB host turns out unstable
---
## ADR-010: Publish UX — atomic `Ctrl-G`, auto-timestamp commit message, no user prompt
**Status:** Accepted — 2026-05-14
**Scope:** Whole project, all releases.
### Context
The device needs an action that ships writing to the git remote. Most
git-using tools expose `commit` and `push` as distinct user gestures, often
with a commit-message prompt. The device's actual user (= the author of this
firmware) already uses the [`gct` shell alias](../CONTEXT.md#user-facing-actions)
for their own writing: `git add . && git commit -m "<timestamp>" && git push`,
with a `git pull --no-edit` fallback when the push fails non-fast-forward.
`gct` is the established workflow; the typewriter mirrors it.
### Options considered
| Option | Pros | Cons |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Three separate gestures** (save / commit / push) | Maximally git-native; user has fine control. | Three keys to remember, three failure modes to surface, three concepts in the user's head. Wrong shape for an appliance whose job is to remove ceremony. |
| **One gesture, prompt for message** (`Ctrl-G` → modal asking for message → commit → push) | Conventional "publish" pattern; each commit is named. | A modal prompt on e-ink is hostile (latency, full refresh); the user's actual workflow (`gct`) explicitly avoids authoring messages; messages would be noise (`"updated notes"` × 1000). |
| **One gesture, auto-timestamp message** (`Ctrl-G` mirrors `gct`) | Matches the user's real workflow; one key, one outcome; no prompts, no modes, no decisions in the writing path. | Commit history is timestamp-noise (useless for code archaeology); a future reader will wonder where the commit messages went; locks in a UX assumption that's hard to undo without breaking muscle memory. |
### Decision
**One gesture, auto-timestamp message, atomic from the user's view.** `Ctrl-G`
runs the full `gct` sequence (stage all → short-circuit if nothing staged →
commit with ISO-8601 timestamp → push → on push failure, `pull --no-edit` then
retry). Failure surfaces as a single retry-able outcome in the status line.
### Consequences
- The user's vocabulary collapses to **Save** and **Publish**;
[`CONTEXT.md`](../CONTEXT.md#user-facing-actions) pins this — *commit* is
not a user-facing term.
- Commit history is a stream of timestamps. The device is a writing tool, not
a code repository — the history is here for recoverability, not narrative.
- The pull-merge-retry path means the device may author merge commits on the
user's behalf, with git's default merge message. Acceptable: the user
doesn't read commit history from the device anyway.
- The previously-planned "commit message prompt" item in v0.7 has been
removed from the roadmap.
- Reversing this later (introducing message prompts) would change the
semantics of `Ctrl-G` and break the user's muscle memory. Hard-to-reverse
by design.
---
## How to add a new ADR
1. Append a new `## ADR-NNN: <title>` section to this file.

View File

@@ -17,9 +17,11 @@ The minimum thing that justifies the hardware existing. Full design:
- [ ] Insert-only editing (no modes yet), backspace, enter, arrow keys
- [ ] Line wrap, no line numbers yet
- [ ] Save on `Ctrl-S` → SD
- [ ] Wi-Fi credentials via captive portal on first boot, stored in NVS
- [ ] `Ctrl-G` runs: `git add notes.md && git commit -m "wip" && git push` to a
pre-configured remote, using a PAT entered during setup
- [ ] Wi-Fi credentials + remote URL + PAT + author baked into the binary at
build time via env vars (no NVS, no on-device provisioning UI in v0.1)
- [ ] `Ctrl-G` runs: `git add .` → commit with an ISO-8601 timestamp message →
`git push`; on push failure, `git pull --no-edit` then retry the push
(no-op short-circuit when nothing is staged). PAT from first-run setup.
- [ ] Partial refresh on edits; full refresh on save
Out of scope: Vim, palette, multiple files, branches, conflict handling.
@@ -52,6 +54,9 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
- [ ] `:e` and palette share the same recent-files list
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
- [ ] Status line briefly shows file count on `Ctrl-G` when the publish bundles
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
so workspace-scoped behaviour stays visible to the user
## v0.6 — Markdown affordances — [ ]
@@ -65,7 +70,6 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
- [ ] `/` forward search, `n N`
- [ ] `:Gpull` (fetch + fast-forward only; refuse on conflict and surface it)
- [ ] `:Gbranch` to switch branches; refuse with dirty tree
- [ ] Commit message prompt instead of hard-coded `"wip"`
## v0.8 — Power: battery + sleep — [ ]
@@ -81,7 +85,9 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
- [ ] Recover from interrupted push (re-attempt on next save)
- [ ] SD card removal / reinsert handling
- [ ] Wi-Fi reconnect with backoff
- [ ] Settings screen: SSID, PAT rotation, default remote, commit author
- [ ] On-device provisioning + settings screen: SSID, PAT rotation, default
remote, commit author (replaces the v0.1 dev-only NVS-flashing path —
first release usable by someone who is not the firmware author)
## v1.0 — Polish — [ ]

View File

@@ -13,7 +13,7 @@
## One-line summary
Boot the device, type into one file, hit `Ctrl-S` to save, hit `Ctrl-G` to
push it to GitHub. That's the entire product surface.
publish it to GitHub. That's the entire product surface.
## Target user
@@ -22,62 +22,49 @@ the loop closes end to end.
## User stories
1. **First run.** I plug in the device for the first time. It walks me through
Wi-Fi setup, asks for a GitHub Personal Access Token and a repository URL,
and stores them. I never want to do this step twice.
2. **Write.** I open the lid (= plug in power). Within a few seconds the
1. **Write.** I open the lid (= plug in power). Within a few seconds the
screen shows my `notes.md` file. I start typing. What I type appears on
screen with sub-second latency.
3. **Save.** I hit `Ctrl-S`. The file on SD is updated. A short status flash
2. **Save.** I hit `Ctrl-S`. The file on SD is updated. A short status flash
confirms it.
4. **Push.** I hit `Ctrl-G`. The device commits and pushes to GitHub. The
status line shows progress; if it succeeds, the commit hash is briefly
shown. If it fails, I see why.
3. **Publish.** I hit `Ctrl-G`. The device ships my latest changes to GitHub
atomically — if the remote has moved on, it merges in (no-edit) and pushes
again, so I only see one outcome. If it succeeds, a short hash is briefly
shown. If it fails, I see why and I can retry.
5. **Recover.** Power gets pulled mid-edit. I plug it back in. My file is at
4. **Recover.** Power gets pulled mid-edit. I plug it back in. My file is at
the last saved state — I lose unsaved changes, but the file isn't
corrupted.
That's it. No other stories ship in v0.1.
## First-run provisioning flow
## Provisioning (build-time, dev-only)
```
[POWER ON, no config in NVS]
Splash: "typewriter v0.1 — first-run setup"
Wi-Fi: device starts AP "typewriter-setup"
Screen shows: SSID, password, http://192.168.4.1
User connects from phone/laptop, enters:
• home Wi-Fi SSID + password
• git remote URL (HTTPS)
• GitHub username
• GitHub PAT (with `repo` scope)
• commit author name + email
Device validates: connects to Wi-Fi, clones the repo into /sd/repo
Screen: "ready. opening notes.md"
[STEADY STATE]
```
v0.1 has **no on-device provisioning** — no captive portal, no in-editor
wizard, no NVS config file, no LittleFS config. The device is the dev's own
machine (see [Target user](#target-user)); config is baked into the firmware
at build time:
If the clone fails (bad PAT, no `notes.md` in the repo, etc.), the screen
shows the error and offers retry. The repo is expected to already exist on
GitHub and contain (or be willing to contain) `notes.md` — v0.1 does not
create remote repos. Auth model rationale:
[ADR-005](adr.md#adr-005-auth--https--github-personal-access-token).
- Wi-Fi SSID + password, git remote URL, GitHub username, PAT, and commit
author name + email are read by `build.rs` from environment variables (or
a `.env` file gitignored locally) and embedded as `const &'static str`s in
the binary.
- The dev clones the git working copy onto the SD card from their laptop
(`git clone` directly onto the mounted SD), so `/sd/repo/notes.md` is
present at first boot.
- Re-configuring = re-build + re-flash. For a single-dev MVP, that's fine.
If the firmware boots and finds no `/sd/repo` or no `/sd/repo/notes.md`, it
shows a one-line error on the splash and stops. No recovery flow ships in
v0.1 — the dev re-mounts the SD and reboots.
On-device provisioning UI (Wi-Fi captive portal, PAT rotation, settings
screen) lands in v0.9 when the first non-dev user becomes a concern; at that
point the constants migrate to NVS-on-LittleFS per
[ADR-007](adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config).
Auth model rationale: [ADR-005](adr.md#adr-005-auth--https--github-personal-access-token).
## Steady-state flow
@@ -96,10 +83,12 @@ create remote repos. Auth model rationale:
├── Ctrl-S → write file, full refresh, status: "saved 14:02"
└── Ctrl-G → save if dirty, then:
git add notes.md
git commit -m "wip <timestamp>"
git add .
(if nothing staged: status "nothing to publish", stop)
git commit -m "<ISO-8601 timestamp>"
git push
status: "pushed abc1234" or "push failed: <reason>"
└─ on failure: git pull --no-edit, retry push
status: "published abc1234" or "publish failed: <reason>"
```
## Screen layout
@@ -150,9 +139,10 @@ don't fight it.
| Failure | What the user sees |
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
| No SD card on boot | "no SD card — insert one and reboot" |
| SD card mounted but `/sd/repo` missing | drop back into first-run setup |
| SD card mounted but `/sd/repo` missing | "missing /sd/repo — re-provision and reboot" (dev re-clones onto SD) |
| Wi-Fi credentials wrong (build-time env vars) | status shows `Wi-Fi ✗`; dev rebuilds with correct env vars and re-flashes |
| Wi-Fi won't connect | status shows `Wi-Fi ✗`; editing still works; `Ctrl-G` shows "no network" |
| `Ctrl-G` push rejected (auth, non-fast-forward, etc.) | status shows reason; commit stays local; user can retry |
| `Ctrl-G` publish fails (auth, network, merge conflict)| status shows reason; local commit is preserved; user can retry — non-fast-forward auto-recovers via pull-then-push and is not surfaced as a failure unless the pull itself conflicts |
| File write fails | status flashes "save failed"; buffer stays dirty |
| Keyboard disconnects | header shows `⌨ ✗`; editing pauses; reconnects automatically |
@@ -169,11 +159,13 @@ Explicitly **not** in this release, to keep it shippable:
- Branches. Always on whatever branch was cloned (default).
- `git pull`. Manual recovery if the remote diverges.
- Conflict handling.
- Commit message editing — always `"wip <timestamp>"`.
- Commit message editing — always an ISO-8601 timestamp, no user prompt ever
(see [`gct`](../CONTEXT.md#user-facing-actions)).
- Battery. Wall power only.
- Sleep / lid-close behaviour.
- Font selection.
- Settings UI after first-run (re-flash or wipe NVS to reconfigure).
- On-device provisioning UI (captive portal, wizard, settings screen). v0.1 is
provisioned out-of-band from the dev's laptop; users are a v0.9 concern.
- Markdown rendering (headings, bold, etc.) — plain text rendering of MD.
- Search.
@@ -184,14 +176,14 @@ to an engineering function with a measured target in
[qfd.md §6](qfd.md#6-critical-performance-budget); that's the
place to check before declaring an item done.
- [ ] First-run setup completes in under 5 minutes including typing in a PAT.
- [ ] After a cold boot with valid config, cursor is ready in ≤ 5 s.
- [ ] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
- [ ] Typing a 1000-word paragraph never drops a keystroke and never lags
more than 300 ms behind the keyboard.
- [ ] `Ctrl-S` durably writes the file (verified by power-cycling immediately
after the status confirms save — file is intact).
- [ ] `Ctrl-G` produces a commit visible on github.com within 30 s on a
typical home Wi-Fi.
typical home Wi-Fi (including the pull-then-retry path when the remote
has moved on since the last publish).
- [ ] Pulling power during typing never corrupts the file; the previous saved
state is recoverable.
- [ ] One hour of continuous typing without crash, freeze, or memory

View File

@@ -45,7 +45,7 @@ blocked by I/O.
| Task | Core | Stack | Responsibility |
| ------------- | ---- | ----- | -------------------------------------------------------- |
| `usb_task` | 0 | 8 KB | TinyUSB host loop, decode HID reports, post `KeyEvent`s |
| `wifi_task` | 0 | 8 KB | Provisioning AP or station mode; expose status |
| `wifi_task` | 0 | 8 KB | Station mode only (no provisioning AP); expose status |
| `ui_task` | 1 | 16 KB | Consume `KeyEvent`s, mutate editor state, enqueue render |
| `render_task` | 1 | 12 KB | Drain render queue, do partial/full refresh on EPD |
| `git_task` | 1 | 32 KB | Triggered by `Ctrl-G`; runs gitoxide commit + push |
@@ -60,11 +60,10 @@ read snapshot (render diff).
```
1. ROM → bootloader → app_main
2. Init PSRAM allocator, set as default for large alloc
3. Mount LittleFS on internal flash → read /nvs/config.toml
├─ no config → enter PROVISIONING mode
└─ config OK → continue
4. Mount FAT on SD → verify /sd/repo exists
├─ missing → enter PROVISIONING mode (clone)
3. Config is compiled into the binary (`build.rs` reads env vars) — no
filesystem read needed for it. LittleFS is unused in v0.1.
4. Mount FAT on SD → verify /sd/repo and /sd/repo/notes.md exist
├─ missing → fatal: "missing /sd/repo — re-mount SD and reboot"
└─ present → continue
5. Init SPI bus (shared: EPD + SD on different CS)
6. Init EPD, full refresh: splash + boot log
@@ -125,7 +124,7 @@ stack decision (e.g. fall back to libgit2; switch to a separate SD SPI bus).
| `←` `→` `↑` `↓` | move cursor (visual lines for ↑↓) |
| `Home` `End` | line start / end |
| `Ctrl-S` | save |
| `Ctrl-G` | save (if dirty) + commit + push |
| `Ctrl-G` | publish (save → stage → commit push, with pull-and-retry on remote divergence) |
Anything else is ignored in v0.1.
@@ -178,21 +177,44 @@ PSRAM heap during push is a top-3 watched metric — see
- `gix` with the smart-HTTP transport backed by `esp-idf` mbedtls (via a
custom transport impl, or `gix-transport` with `reqwest`+`rustls-mbedtls`
if that path is cleaner — decided in spike 7).
- Operations needed in v0.1:
- Operations needed in v0.1 (the [`gct` shell function](../CONTEXT.md#user-facing-actions) is the reference):
- `gix::open` the existing working copy at `/sd/repo`
- stage `notes.md` (`gix::index` add)
- commit with author from config, message `"wip <ISO-8601 timestamp>"`
- stage everything under the working copy (`git add .` equivalent)
- short-circuit return if nothing is staged — status: "nothing to publish"
- commit with author from config, message `"<ISO-8601 timestamp>"` (no `wip`
prefix; the timestamp *is* the message)
- push HEAD to `origin/<current branch>`
- on push failure: `git pull --no-edit` (merge), then retry the push once.
Only surface failure to the status line if the pull conflicts or the
second push also fails.
- The PAT is loaded into the Authorization header per request; never logged.
- Push errors propagate as a string back to the status line.
- The whole sequence is atomic from the user's view — see
[`CONTEXT.md` → Publish](../CONTEXT.md#user-facing-actions).
### `provisioning`first-run wizard
### Provisioning — build-time only (no module on device)
- Triggered when `config.toml` is absent OR `/sd/repo` is absent.
- Captive portal posts a JSON blob; device validates by:
1. Connecting to the supplied Wi-Fi credentials.
2. Cloning the supplied repo URL into `/sd/repo` using the supplied PAT.
- Only on both successes does it persist config and reboot into steady state.
v0.1 has **no provisioning module, no NVS config, no LittleFS mount**. All
config (Wi-Fi creds, remote URL, GitHub user, PAT, commit author) is supplied
as environment variables at build time:
```sh
export TW_WIFI_SSID=... TW_WIFI_PASS=...
export TW_REMOTE_URL=... TW_GH_USER=... TW_PAT=...
export TW_AUTHOR_NAME=... TW_AUTHOR_EMAIL=...
cargo espflash --release
```
`build.rs` reads these (or fails the build) and emits constants the runtime
references directly. The `.env` file used to source these is gitignored.
The git working copy is set up out-of-band: the dev clones the remote onto
the mounted SD card from their laptop. There is no "first clone on device"
in v0.1.
Net savings vs. an on-device wizard: ~300500 LoC of firmware (HTTP server,
captive AP, form parsing, validation state machine, NVS read/write, TOML
parser). On-device provisioning + NVS-backed config land in v0.9 when
non-dev users enter the picture.
## Memory plan