diff --git a/README.md b/README.md index b423e96..3be1282 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ gantt | [v0.4](docs/roadmap.md#v04--visual-mode--ex-commands--) | Visual + ex | `v V`, `:w :q :e`, status line. | | [v0.5](docs/roadmap.md#v05--file-palette--multi-file--) | Files | `Ctrl-P` over `/repo` + `/local`, buffers. | | [v0.6](docs/roadmap.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. | -| [v0.7](docs/roadmap.md#v07--search--better-git--) | Search + git | `/`, `:Gpull`, `:Gbranch`, commit msg. | +| [v0.7](docs/roadmap.md#v07--search--better-git--) | Search + git | `/`, `:Gpull`, `:Gbranch`. | | [v0.8](docs/roadmap.md#v08--power-battery--sleep--) | Power | 18650 + sleep + lid switch. | | [v0.9](docs/roadmap.md#v09--robustness--) | Robustness | Crash-safe writes, reconnect, settings. | | [v1.0](docs/roadmap.md#v10--polish--) | Polish | Boot ≤ 3 s, fonts, enclosure, guide. | diff --git a/docs/adr.md b/docs/adr.md index c45866a..6077311 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -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 "" && 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: ` section to this file. diff --git a/docs/roadmap.md b/docs/roadmap.md index 60d3574..663dab0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 — [ ] diff --git a/docs/v0.1-mvp-product.md b/docs/v0.1-mvp-product.md index 7f3c01d..294e76b 100644 --- a/docs/v0.1-mvp-product.md +++ b/docs/v0.1-mvp-product.md @@ -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 diff --git a/docs/v0.1-mvp-technical.md b/docs/v0.1-mvp-technical.md index 690fbed..bd51d6a 100644 --- a/docs/v0.1-mvp-technical.md +++ b/docs/v0.1-mvp-technical.md @@ -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: ~300–500 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