diff --git a/CONTEXT.md b/CONTEXT.md index 402dd71..c2b0faa 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -87,14 +87,14 @@ _Avoid_: edit area, text area, main pane (superseded — they named the old full-width text region before the side panel carved out its right edge). **Side panel**: -The right region (~160 px / ~17 cols at its FONT_9X15 metadata font, full +The right region (~160 px / ~17 cols at its FONT*9X15 metadata font, full height) holding all metadata: filename + dirty dot, word count, elapsed time, clock, Wi-Fi, keyboard-disconnect flag, publish state, and the mode indicator at its bottom-left. Sits entirely in the master half (right of the `x = 396` seam). Every field is static, event-driven, or throttled — never per-keystroke. -_Avoid_: header, status line, status bar (retired — the old top header band and +\_Avoid*: header, status line, status bar (retired — the old top header band and bottom status band are both collapsed into this one right-hand region); sidebar. Do not write bare **panel**: it collides with the **transient panel** (the modal full-screen help/config view that swaps in over the editor — a later diff --git a/GLOSSARY.md b/GLOSSARY.md index d7b10ea..d9ef065 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -77,6 +77,6 @@ catch them early next time. - **Function-vs-characteristic conflation.** Calling HOWs "engineering functions". A function is a transformation; a - characteristic is one of its measurable attributes. HOWs *measure* + characteristic is one of its measurable attributes. HOWs _measure_ functions; they are not functions. (See [`docs/qfd.md`](docs/qfd.md) §8 for the rename cascade.) diff --git a/MEMORY_AUDIT.md b/MEMORY_AUDIT.md index 1c084d6..26485cc 100644 --- a/MEMORY_AUDIT.md +++ b/MEMORY_AUDIT.md @@ -41,9 +41,9 @@ operation. UB at all. Right architecture; most of the score. - Every `unsafe` site carries real SAFETY reasoning, and the one place untrusted device data sizes a raw slice (`report_cb`) is correctly clamped. -- Not a 9–10 because finding #1 is closed by an *assumed* event ordering rather +- Not a 9–10 because finding #1 is closed by an _assumed_ event ordering rather than by construction, and findings #2–#3 are latent, dependency-sensitive - risks. Real memory safety means the invariant is *enforced*, not hoped for. + risks. Real memory safety means the invariant is _enforced_, not hoped for. - Closing #1 so the in-flight invariant is explicit → 9. A 10 on FFI this heavy needs the structural guards in the "Regression testing" section. @@ -51,7 +51,7 @@ operation. is host-tested (see per-finding status). Once the hot-plug bench run confirms #1 on hardware this becomes a 9; #2 and #4 are the remaining gap to 10. -This is a *memory-safety* score. Robustness (leaks on hot-plug) and correctness +This is a _memory-safety_ score. Robustness (leaks on hot-plug) and correctness would score separately and slightly lower. ## Findings @@ -69,7 +69,7 @@ if !report_xfer.is_null() { The interrupt-IN transfer is resubmitted on every completion (`report_cb`, line 224), so it is **in-flight** most of the time. `report_cb` only fires from -inside `usb_host_client_handle_events` (line 159); the free happens *after* that +inside `usb_host_client_handle_events` (line 159); the free happens _after_ that call returns. The code implicitly relies on the transfer's final canceled-completion callback having already run in the same `handle_events` batch that delivered `DEV_GONE`. @@ -99,7 +99,7 @@ pointer / proceed to `device_close` while it reports the transfer busy. **Status: fixed in code.** A `REPORT_INFLIGHT` flag is set on submit and cleared first thing in every `report_cb`. The teardown moved into a `close_device` helper that pumps client events (bounded) until the transfer quiesces before -freeing it — and *leaks it rather than frees* if it never does, since a leak is +freeing it — and _leaks it rather than frees_ if it never does, since a leak is recoverable and a UAF is not. `usb_host_transfer_free`'s return value is now checked. Needs the hot-plug bench run below to confirm on hardware. @@ -121,7 +121,7 @@ at least add a static/compile-time assertion (or a test) that pins the zero-is-valid assumption so a dependency bump fails loudly. **Status: left as-is deliberately** (sound today, low severity). There is no -clean *stable* compile-time "all-zero is a valid bit pattern" assertion, and a +clean _stable_ compile-time "all-zero is a valid bit pattern" assertion, and a runtime canary can't fire before the UB it would guard (`assume_init` is the UB site). The `addr_of_mut!` rewrite is the real fix but churns five call sites for a latent-only risk; deferred until an `esp-idf-sys` bump makes it worth it. @@ -225,7 +225,7 @@ split by what's reachable where, ranked by leverage. new `unsafe` to carry a SAFETY comment — keeps the existing discipline from eroding. **Deferred:** turning it on crate-wide today floods warnings on the many existing bare-FFI `unsafe` one-liners, which trains people to ignore it. - Worth doing *after* a pass that adds `// SAFETY:` to the existing blocks. + Worth doing _after_ a pass that adds `// SAFETY:` to the existing blocks. 4. **On-device tests for what only exists on device (#1, #3, #4).** - **Hot-plug stress loop**: attach/detach ~100× on a bench script, log `esp_get_free_heap_size` each cycle. A downward trend proves the leaks diff --git a/docs/adr.md b/docs/adr.md index 9853bfb..f6e1090 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -359,7 +359,7 @@ derived key. `FR_EXIST` on an existing destination — it does **not** replace like POSIX `rename(2)`. So the atomic save must `f_unlink` the target before renaming the `*.tmp` over it, and pair that with **boot recovery** of a lingering `*.tmp`. - Recovery is *not* simply "promote the tmp": a crash *during* the tmp write + Recovery is _not_ simply "promote the tmp": a crash _during_ the tmp write leaves a partial tmp, so the choice depends on whether the target survived — - **tmp + target both present** → the crash could have been mid-write, so the tmp is untrustworthy. Keep the committed target, discard the tmp (this is the @@ -369,6 +369,7 @@ derived key. Implemented in `firmware::persistence::Storage::{save,recover}`. See the [Spike 3 postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md#resolution-2026-07-11). + - **SD-card compatibility:** use a genuine card, ideally **≤32 GB (SDHC/FAT32)**. Large or counterfeit SDXC cards may reject `CMD59` (SPI-mode CRC) and fail to mount; we keep CRC required rather than run the user's writing over an @@ -503,7 +504,7 @@ retry). Failure surfaces as a single retry-able outcome in the status line. [ADR-005](#adr-005-auth--https--github-personal-access-token) decided the auth model (HTTPS + PAT) and sketched an endgame ("from v0.9 the PAT moves to -encrypted storage with an eFuse-derived key"), but left the *mechanics* open: +encrypted storage with an eFuse-derived key"), but left the _mechanics_ open: **how does a token get onto a device, and how is it protected once there?** Spike 7 made this concrete. The PAT is currently **baked into the firmware image @@ -518,21 +519,21 @@ deliberate v0.1 shortcut. On a real device that means: This is fine for the dev's own bench unit (it's their token, their device) and is why the [Spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md) -lists it as the *last standing shortcut*. It is not fine for a unit in anyone +lists it as the _last standing shortcut_. It is not fine for a unit in anyone else's hands. Resolving it needs a **provisioning path**, which the current design (["build-time only, no provisioning module"](v0.1-mvp-technical.md#provisioning--build-time-only-no-module-on-device)) deliberately omits. ### Options considered -| Option | Pros | Cons | -| --- | --- | --- | -| **Build-time bake** (current, ADR-005 v0.1) | Zero UX; nothing to build. | Plaintext in flash; same token per unit; reflash to rotate. **Dev-bench only.** | -| **On-device paste → NVS (plaintext)** | No reflash; per-device token. | Still plaintext at rest; needs a first-run entry UI (captive portal / keyboard). | -| **On-device paste → NVS encrypted (eFuse key)** | ADR-005's stated target; a flash dump alone doesn't yield the token; per-device + rotatable. | Needs NVS encryption + eFuse key derivation + first-run UI — the whole provisioning module. | -| **USB provisioning tool** (host writes NVS over serial) | No on-device text entry; scriptable at assembly. | Needs a host-side tool; token still needs at-rest protection (combine with encryption). | -| **Per-device fine-grained PAT** (orthogonal) | Least blast radius; per-device revoke; repo-scoped. | User mints one PAT per device; pairs with any storage option above. | -| **GitHub App installation token** | Strongest, rotating creds. | Heavy for a single-user appliance — rejected in ADR-005 for overhead. | +| Option | Pros | Cons | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| **Build-time bake** (current, ADR-005 v0.1) | Zero UX; nothing to build. | Plaintext in flash; same token per unit; reflash to rotate. **Dev-bench only.** | +| **On-device paste → NVS (plaintext)** | No reflash; per-device token. | Still plaintext at rest; needs a first-run entry UI (captive portal / keyboard). | +| **On-device paste → NVS encrypted (eFuse key)** | ADR-005's stated target; a flash dump alone doesn't yield the token; per-device + rotatable. | Needs NVS encryption + eFuse key derivation + first-run UI — the whole provisioning module. | +| **USB provisioning tool** (host writes NVS over serial) | No on-device text entry; scriptable at assembly. | Needs a host-side tool; token still needs at-rest protection (combine with encryption). | +| **Per-device fine-grained PAT** (orthogonal) | Least blast radius; per-device revoke; repo-scoped. | User mints one PAT per device; pairs with any storage option above. | +| **GitHub App installation token** | Strongest, rotating creds. | Heavy for a single-user appliance — rejected in ADR-005 for overhead. | ### Decision @@ -580,10 +581,10 @@ and EPD access are genuinely concurrent. ### Options considered -| Option | Pros | Cons | -| --- | --- | --- | -| **Shared SPI2, arbitrate** | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. | -| **SD on its own SPI3** | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. | +| Option | Pros | Cons | +| -------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Shared SPI2, arbitrate** | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. | +| **SD on its own SPI3** | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. | ### Decision diff --git a/docs/hardware.md b/docs/hardware.md index 9c4d077..8eb8c28 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -7,14 +7,14 @@ medium decision (e-ink over LCD / memory LCD / OLED) is keyboard transport is [ADR-009](adr.md#adr-009-keyboard-transport--usb-host-tinyusb). -| 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: ~13 lines, ~79 cols at the editor's 10px font. 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 v0.8 | 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 typewriter body — [`hardware/case/`](../hardware/case/README.md) | v1.0 concern. | +| 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: ~13 lines, ~79 cols at the editor's 10px font. 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 v0.8 | 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 typewriter body — [`hardware/case/`](../hardware/case/README.md) | v1.0 concern. | ## Why the strip aspect diff --git a/docs/kaizen/real-repo-sync.md b/docs/kaizen/real-repo-sync.md index 4c8b4ed..ed50b2c 100644 --- a/docs/kaizen/real-repo-sync.md +++ b/docs/kaizen/real-repo-sync.md @@ -251,15 +251,15 @@ on-device telemetry built for that run. The step-4 mitigation "mwindow 256 KB / 4 MB" turned out to be necessary but nowhere near sufficient. The verification runs surfaced, in order: -| Factor | Hypothesis | Test method | Test result | True or false? | -| ------ | ---------- | ----------- | ----------- | -------------- | -| P1 — UI death by heap | The editor's per-draw `vec![0xFF; 26928]` framebuffer aborts the firmware when the push exhausts the heap (run 4: 66 s silent push, then abort on a keystroke) | backtrace + allocator fallback math (internal 23 KB < 27 KB ⇒ PSRAM had no 27 KB block either) | **TRUE** — a C `malloc` failure inside libgit2 returns an error; a Rust `Vec` failure aborts the process | fix: two boot-time frames, `draw_into` + `mem::swap` — steady-state repaints never allocate | -| P2 — odb cache is the ~6 MB eater | libgit2's odb object cache (default max 256 MB — unbounded here) caches every tree/commit read during pack building | cap it at 1 MB + read `GIT_OPT_GET_CACHED_MEMORY` in a per-run heap line | cache used **59 KB** of its 1 MB cap at the moment of death | **FALSE** (cap kept as insurance) | -| P3 — progress callbacks would show the slope | `pack_progress` throttled by object count will trace the silent stretch | run 6: zero lines in 65 s; then source read | count gate can't fire (`AddingObjects` reports `total = 0`, a 2-commit push inserts dozens of objects) — and deeper: the whole 65 s is `mark_edges_uninteresting` reading origin tip's ~1200-object tree **before the first insert**, a phase with no callbacks at all | **TRUE (as a bug in our telemetry)** — fix: 5 s sibling heartbeat thread through `remote.push()` | -| P4 — libgit2's mmaps are the eater | every "mmap" is a real PSRAM malloc in `esp_map.c`; its live-bytes counter should track the drop | wire `esp_map_stats()` into the heap line + heartbeat | run 7: mmap-live tracked the PSRAM drop 1:1, **plateau 7151 KB of the 8 MB pool**, min-ever 472 B, 19.9 MB read off the card to push two commits | **TRUE** | -| P5 — the budget itself is impossible | fixed whole-file index maps live **outside** the mwindow budget | inspect the source clone | 5 packs, 562 MB: 1730 KB multi-pack-index + ~1737 KB of `.idx` = **~3.4 MB fixed**, plus windows at the (working, soft) 4 MB limit | **TRUE** — fix: mwindow **64 KB windows / 1.5 MB budget** | -| P6 — keep-alive dies during the marking gap | with memory fixed (run 8: min-ever 2.05 MB, marking 32 s), the push died at the **first write** of the upload: `SSL error: Generic error` after ~31 s of wire silence between the negotiation GET and the pack POST | timeline correlation (pack had built fine — 11 objects, deltafication complete) | GitHub/router closes the idle connection ~30 s; libgit2 treats the dead socket as fatal, no reconnect | **TRUE** | -| P7 — pack layout is a device-performance variable | `git repack -ad` (5 packs → 1, midx gone) shrinks the fixed maps *and* clusters trees for the marking walk | repack the source clone, reload the card, run 9 | marking **3.5 s** (was 32 s) — the whole boundary walk hit ONE 1740 KB idx + **two** 64 KB windows; push accepted | **TRUE** | +| Factor | Hypothesis | Test method | Test result | True or false? | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| P1 — UI death by heap | The editor's per-draw `vec![0xFF; 26928]` framebuffer aborts the firmware when the push exhausts the heap (run 4: 66 s silent push, then abort on a keystroke) | backtrace + allocator fallback math (internal 23 KB < 27 KB ⇒ PSRAM had no 27 KB block either) | **TRUE** — a C `malloc` failure inside libgit2 returns an error; a Rust `Vec` failure aborts the process | fix: two boot-time frames, `draw_into` + `mem::swap` — steady-state repaints never allocate | +| P2 — odb cache is the ~6 MB eater | libgit2's odb object cache (default max 256 MB — unbounded here) caches every tree/commit read during pack building | cap it at 1 MB + read `GIT_OPT_GET_CACHED_MEMORY` in a per-run heap line | cache used **59 KB** of its 1 MB cap at the moment of death | **FALSE** (cap kept as insurance) | +| P3 — progress callbacks would show the slope | `pack_progress` throttled by object count will trace the silent stretch | run 6: zero lines in 65 s; then source read | count gate can't fire (`AddingObjects` reports `total = 0`, a 2-commit push inserts dozens of objects) — and deeper: the whole 65 s is `mark_edges_uninteresting` reading origin tip's ~1200-object tree **before the first insert**, a phase with no callbacks at all | **TRUE (as a bug in our telemetry)** — fix: 5 s sibling heartbeat thread through `remote.push()` | +| P4 — libgit2's mmaps are the eater | every "mmap" is a real PSRAM malloc in `esp_map.c`; its live-bytes counter should track the drop | wire `esp_map_stats()` into the heap line + heartbeat | run 7: mmap-live tracked the PSRAM drop 1:1, **plateau 7151 KB of the 8 MB pool**, min-ever 472 B, 19.9 MB read off the card to push two commits | **TRUE** | +| P5 — the budget itself is impossible | fixed whole-file index maps live **outside** the mwindow budget | inspect the source clone | 5 packs, 562 MB: 1730 KB multi-pack-index + ~1737 KB of `.idx` = **~3.4 MB fixed**, plus windows at the (working, soft) 4 MB limit | **TRUE** — fix: mwindow **64 KB windows / 1.5 MB budget** | +| P6 — keep-alive dies during the marking gap | with memory fixed (run 8: min-ever 2.05 MB, marking 32 s), the push died at the **first write** of the upload: `SSL error: Generic error` after ~31 s of wire silence between the negotiation GET and the pack POST | timeline correlation (pack had built fine — 11 objects, deltafication complete) | GitHub/router closes the idle connection ~30 s; libgit2 treats the dead socket as fatal, no reconnect | **TRUE** | +| P7 — pack layout is a device-performance variable | `git repack -ad` (5 packs → 1, midx gone) shrinks the fixed maps _and_ clusters trees for the marking walk | repack the source clone, reload the card, run 9 | marking **3.5 s** (was 32 s) — the whole boundary walk hit ONE 1740 KB idx + **two** 64 KB windows; push accepted | **TRUE** | Run 9's heap line, for the record: min-ever PSRAM **4.5 MB free** (was 472 B three runs earlier), mmap live 1868 KB, odb cache 70 KB. @@ -343,7 +343,7 @@ historical record, the owner is authoritative) repacks the source clone (best-effort, like the pull) whenever it has more than one pack or a multi-pack-index. - File the libgit2 upstream bug report (tlsf double-free in the mbedTLS - stream error path). *(Unscheduled chore — not version-scoped.)* + stream error path). _(Unscheduled chore — not version-scoped.)_ - Instrument the residual ~360 ms/loose-write (suspect: FAT directory-op cost in the freshen/refresh path) — one `sd_bench` + `p_mmap`-miss logging pass. → [v0.7](../v0.7-search-and-git.md). diff --git a/docs/notes/README.md b/docs/notes/README.md index 90519c5..f46ce6d 100644 --- a/docs/notes/README.md +++ b/docs/notes/README.md @@ -6,9 +6,9 @@ > Docs index: [`../README.md`](../README.md). Project overview: > [`../../README.md`](../../README.md). -| Note | What it argues | -| --- | --- | -| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 5–10 s `Ctrl-G` push feel instant. | -| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. | -| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. | -| [`sync-latency.md`](sync-latency.md) | Where the ~16 s cold `:sync` goes — Wi-Fi + SNTP + one TLS push; why optimistic-retry cut a whole handshake, and why the rest is close to the protocol floor. | +| Note | What it argues | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 5–10 s `Ctrl-G` push feel instant. | +| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. | +| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. | +| [`sync-latency.md`](sync-latency.md) | Where the ~16 s cold `:sync` goes — Wi-Fi + SNTP + one TLS push; why optimistic-retry cut a whole handshake, and why the rest is close to the protocol floor. | diff --git a/docs/notes/boot-time-budget.md b/docs/notes/boot-time-budget.md index cd9d0a7..dd0f3ea 100644 --- a/docs/notes/boot-time-budget.md +++ b/docs/notes/boot-time-budget.md @@ -16,25 +16,25 @@ From the boot serial log (power-on → first editor frame + input loop live): -| Phase | ~ms | Lever | -| --- | ---: | --- | -| ROM + 2nd-stage bootloader + app image load | ~550 | flash speed (DIO now; QIO / 80 MHz ≈ −200 ms, speculative) | -| PSRAM init + **memtest** + heap | ~920 | `CONFIG_SPIRAM_MEMTEST=n` → **−730 ms** (kept **on**: a real HW sanity check on a hand-wired board) | -| EPD reset + init | ~130 | fixed panel bring-up | -| **Splash full refresh** | ~1850 | e-ink floor — see below | -| SD mount + note load | ~70 | quick on the genuine 32 GB SDHC | -| USB host install + git thread spawn | ~60 | background | -| **First editor render** (full-area partial) | ~680 | already fixed from ~1870 ms (was a *second* full refresh) | -| **Total** | **~4260** | | +| Phase | ~ms | Lever | +| ------------------------------------------- | --------: | --------------------------------------------------------------------------------------------------- | +| ROM + 2nd-stage bootloader + app image load | ~550 | flash speed (DIO now; QIO / 80 MHz ≈ −200 ms, speculative) | +| PSRAM init + **memtest** + heap | ~920 | `CONFIG_SPIRAM_MEMTEST=n` → **−730 ms** (kept **on**: a real HW sanity check on a hand-wired board) | +| EPD reset + init | ~130 | fixed panel bring-up | +| **Splash full refresh** | ~1850 | e-ink floor — see below | +| SD mount + note load | ~70 | quick on the genuine 32 GB SDHC | +| USB host install + git thread spawn | ~60 | background | +| **First editor render** (full-area partial) | ~680 | already fixed from ~1870 ms (was a _second_ full refresh) | +| **Total** | **~4260** | | Two lines carry the weight: the **splash full refresh (~1.85 s)** and the **first editor render (~0.68 s)**. Everything else is ≤ ~0.9 s combined, and the -biggest of *those* — the ~0.73 s PSRAM memtest — is a deliberate keep. +biggest of _those_ — the ~0.73 s PSRAM memtest — is a deliberate keep. ## The insight: one full refresh is unavoidable, so the splash is nearly free After power-on the panel controller's `0x26` "previous" RAM bank holds garbage. A -partial refresh *diffs the new image against that bank* +partial refresh _diffs the new image against that bank_ ([`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md)), so the **first clean paint must be a full refresh** (~1.9 s) to establish a known image. There is no way around this on this panel short of a different waveform. @@ -42,12 +42,12 @@ image. There is no way around this on this panel short of a different waveform. That reframes two things: - **The splash costs almost nothing.** Boot needs one full refresh regardless; the - splash simply *is* that refresh, turned into a "boot is happening" affordance. + splash simply _is_ that refresh, turned into a "boot is happening" affordance. Dropping the splash would **not** save the 1.9 s — the editor's first frame would then have to be the full refresh instead. (This is exactly what the old boot did - and why it paid *two* full refreshes.) + and why it paid _two_ full refreshes.) - **The v0.1 win was removing the second full refresh, not the first.** Once the - splash has seeded a clean baseline, the editor rides in on a full-area *partial* + splash has seeded a clean baseline, the editor rides in on a full-area _partial_ (~0.63 s) instead of a second full refresh (~1.9 s) — the ~1.25 s saving that took cold boot from ~5.5 s to ~4.26 s. Verified clean on-panel (no splash ghost behind the editor text). @@ -60,7 +60,7 @@ To go from ~4.26 s to ≤ 3 s needs ~1.26 s cut. The honest lever list: reasonable once the board is no longer hand-wired. - **Faster flash boot (QIO / 80 MHz):** ~−0.2 s, speculative, needs a bench check. - **Overlap cheap init under the splash busy-wait:** SD mount + note load + USB - install (~0.13 s total) currently run *after* the splash refresh returns, but + install (~0.13 s total) currently run _after_ the splash refresh returns, but the refresh is a `wait_while_busy` spin — those could be kicked off before it. Saves ~0.1 s at most. diff --git a/docs/notes/git-sync-images-and-repo-size.md b/docs/notes/git-sync-images-and-repo-size.md index e947a57..b38c60b 100644 --- a/docs/notes/git-sync-images-and-repo-size.md +++ b/docs/notes/git-sync-images-and-repo-size.md @@ -13,15 +13,15 @@ reasons worth writing down before anyone force-pushes a rewritten history. ## The measured reality -| Metric (target repo, measured 2026-07-07) | Value | -| --- | --- | -| Working tree | 3.9 GB (dominated by `node_modules`) | -| `.git` (what a clone actually transfers) | **566 MB** | -| Commits / objects | 13,852 / 63,252 | -| Depth-1 snapshot (HEAD tree + blobs) | **154.7 MB** | -| — of which markdown (the notes) | **1.4 MB** | -| — of which media (png/jpg/pdf/gif/bmp) | **153 MB** | -| Media across *all* history (dedup) | 566 MB (715 PNG objects = 463 MB) | +| Metric (target repo, measured 2026-07-07) | Value | +| ----------------------------------------- | ------------------------------------ | +| Working tree | 3.9 GB (dominated by `node_modules`) | +| `.git` (what a clone actually transfers) | **566 MB** | +| Commits / objects | 13,852 / 63,252 | +| Depth-1 snapshot (HEAD tree + blobs) | **154.7 MB** | +| — of which markdown (the notes) | **1.4 MB** | +| — of which media (png/jpg/pdf/gif/bmp) | **153 MB** | +| Media across _all_ history (dedup) | 566 MB (715 PNG objects = 463 MB) | Two early assumptions corrected: @@ -38,7 +38,7 @@ Two early assumptions corrected: The clean fix for "device only needs 1.4 MB of text" would be a blobless partial clone (`--filter=blob:none`) — **libgit2 does not support it**. The fallbacks are LFS migration, a filter-repo history purge, or `git rm` of media -at HEAD. All three remove the image *blobs* from what a git client sees. +at HEAD. All three remove the image _blobs_ from what a git client sees. That breaks **remanso**, the web app that reads the same repo. remanso is a frontend with no server-side git; it displays a note image by reading the @@ -50,7 +50,7 @@ image straight out of git as a blob and inlining it as a data URI 3. `GET /repos/{owner}/{repo}/git/blobs/{sha}` → **base64 of the git blob** 4. `img.src = "data:image/jpeg;base64," + thatContent` -The image *is* the git blob. GitHub's Git Blobs / Trees / Contents APIs do +The image _is_ the git blob. GitHub's Git Blobs / Trees / Contents APIs do **not** resolve LFS (only `media.githubusercontent.com` does, which remanso doesn't use). So after an LFS migration those endpoints return the ~130-byte **pointer text**, remanso wraps it in `data:image/jpeg;base64,…`, and every @@ -62,20 +62,20 @@ the blobs from the tree, so remanso can't find them either. **Any approach that takes the images out of the git repo breaks remanso**, because those 150 MB are load-bearing infrastructure for the web app, not offloadable bloat. -| Consumer | How it reads images | If we shrink the repo | -| --- | --- | --- | -| Typoena (libgit2) | doesn't render images; needs valid git objects | fine — would get tiny pointers | -| remanso (Blobs API → data URI) | reads image bytes straight out of git | **broken** — pointer/missing bytes render as a dead image; uploads bypass LFS | +| Consumer | How it reads images | If we shrink the repo | +| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------- | +| Typoena (libgit2) | doesn't render images; needs valid git objects | fine — would get tiny pointers | +| remanso (Blobs API → data URI) | reads image bytes straight out of git | **broken** — pointer/missing bytes render as a dead image; uploads bypass LFS | ## Decision **Leave the notes repo untouched. Pre-seed the device SD card with a full `git clone` from a computer.** -Repo size is only a *device* constraint when the *device* does the cold clone. +Repo size is only a _device_ constraint when the _device_ does the cold clone. A laptop clones 566 MB in ~2 minutes onto the SD via a card reader; the SD has GB to spare. The device then only ever takes the `open` + incremental -fetch/commit/push path (`open_or_clone` already splits on this). A *full* +fetch/commit/push path (`open_or_clone` already splits on this). A _full_ pre-seed (not depth-1) also sidesteps the shallow-push sharp edge. remanso keeps working, the device gets everything, and repo size stops being anyone's problem. @@ -87,7 +87,7 @@ done so it goes straight into Typoena: the card's `repo/`, excluding everything the repo's `.gitignore` ignores (so `node_modules` and local secrets like `firmware/.env` never land on the card), then writes `/sd/typoena.conf` — Wi-Fi creds + PAT + git identity — from the - TW_* vars already in `firmware/.env` (no re-typing, no prompts). + TW\_\* vars already in `firmware/.env` (no re-typing, no prompts). - `just load ` — the repo copy on its own (refresh after big upstream changes). - `just provision` — the config on its own (rotate the PAT / switch networks @@ -112,7 +112,7 @@ Once integration lands, the costs of carrying media the device never renders: 1. **Bandwidth for unusable bytes.** No partial fetch in libgit2, so a fetch pulls the full new image blobs. 20 MB of pasted screenshots = a 20 MB fetch before a one-line note can publish. -2. **~2× SD storage.** Each image lives in `.git` *and* in the working-tree +2. **~2× SD storage.** Each image lives in `.git` _and_ in the working-tree checkout that `checkout_head(force)` writes. 3. **Memory — the real edge.** libgit2 tends to materialize a whole blob in RAM for checkout. History already has a 38 MB mp3 and 16 MB PNGs. Against diff --git a/docs/notes/sync-latency.md b/docs/notes/sync-latency.md index b242835..d207e16 100644 --- a/docs/notes/sync-latency.md +++ b/docs/notes/sync-latency.md @@ -18,23 +18,23 @@ From the serial log, first `:sync` after a cold boot (`… wifi 3654ms, clock 2108ms, tls 304ms, publish(commit+push) 9944ms, total 16012ms`): -| Phase | ~ms | One-time? | Lever | -| --- | ---: | --- | --- | -| Wi-Fi assoc + DHCP | ~3650 | yes (per power cycle) | radio off until first `:sync`; association floor | -| SNTP first sync | ~2100 | yes | varies with NTP RTT (4.2 s the prior run); needed before TLS + commit time | -| TLS trust store install | ~300 | yes | write ~6 KB CA bundle to SD + set libgit2 option | -| **publish** = stage+commit + push | **~9900** | **every sync** | see below | -| **Total** | **~16000** | | | +| Phase | ~ms | One-time? | Lever | +| --------------------------------- | ---------: | --------------------- | -------------------------------------------------------------------------- | +| Wi-Fi assoc + DHCP | ~3650 | yes (per power cycle) | radio off until first `:sync`; association floor | +| SNTP first sync | ~2100 | yes | varies with NTP RTT (4.2 s the prior run); needed before TLS + commit time | +| TLS trust store install | ~300 | yes | write ~6 KB CA bundle to SD + set libgit2 option | +| **publish** = stage+commit + push | **~9900** | **every sync** | see below | +| **Total** | **~16000** | | | -The three one-time phases (~6.1 s) only pay on the *first* sync of a power cycle — +The three one-time phases (~6.1 s) only pay on the _first_ sync of a power cycle — Wi-Fi, the clock, and the trust store are set up once and reused, so a **warm sync is just the ~10 s publish**. Publish splits as: -| Sub-phase | ~ms | Note | -| --- | ---: | --- | -| stage + commit | ~3150 | `add_all(["*"])` walking the SD/FAT working tree, then commit to FAT | -| push: TLS handshake | ~2400 | one mbedTLS handshake to github.com | -| push: pack negotiate + upload | ~4400 | tiny delta — cost is negotiation/round-trips, not payload | +| Sub-phase | ~ms | Note | +| ----------------------------- | ----: | -------------------------------------------------------------------- | +| stage + commit | ~3150 | `add_all(["*"])` walking the SD/FAT working tree, then commit to FAT | +| push: TLS handshake | ~2400 | one mbedTLS handshake to github.com | +| push: pack negotiate + upload | ~4400 | tiny delta — cost is negotiation/round-trips, not payload | ## The win: one TLS handshake, not two @@ -45,7 +45,7 @@ sync (remote unchanged), and it did ~6 s of real work the one time it absorbed a maintenance commit. The optimistic-retry rewrite (commit `3386969`) drops it: **push onto the current -tip first**; only if the remote *rejects* the push non-fast-forward do we fetch, +tip first**; only if the remote _rejects_ the push non-fast-forward do we fetch, reconcile, and retry. The happy path — what runs ~99 % of the time — is now a **single** handshake. That took the true normal-cold baseline from ~19 s to **16.0 s** (and the inflated 23.7 s figure will never recur, since it was the @@ -57,8 +57,8 @@ On a rejected push, `reconcile_onto_origin` fetches origin and does a **mixed** reset onto it — moving the branch ref + index but leaving the working tree, so the just-saved note survives — then `stage_and_commit` replays the note on the new tip and retries. For this **single-writer appliance** that resolves last-writer-wins: -a concurrent remote *edit* to the same note loses to ours, and a remote-only -*added* file the card doesn't have would be dropped by the replay's `add --all`. +a concurrent remote _edit_ to the same note loses to ours, and a remote-only +_added_ file the card doesn't have would be dropped by the replay's `add --all`. Both need a real merge (increment B) and don't arise from the device's own use. This path is **hardware-verified for the happy case** (`9b635c42` fast-forwarded clean); the reconcile branch itself is compile-verified but not yet exercised on @@ -77,7 +77,7 @@ The big rocks are physics or protocol, not slack: shave. - **stage + commit ~3.1 s** is the one soft spot: staging over the editor's dirty set (`add_path`) instead of `add_all(["*"])` would skip the SD/FAT tree walk - (likely → sub-second) *without* losing multi-file — the dirty set is the file + (likely → sub-second) _without_ losing multi-file — the dirty set is the file list. Whether the walk actually dominates the ~4 s commit is now being measured by the `commit split —` log line; the cost model and the rule it decides live in [`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md). @@ -86,5 +86,5 @@ The big rocks are physics or protocol, not slack: one TLS push over Wi-Fi with a fresh clock." It reads as slow only if you wait on it — and by design you don't: `:sync` is a deliberate action with a snackbar, and [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) argues the perceived -cost is set by *when durability is surfaced*, not by wall-clock. Recorded here so +cost is set by _when durability is surfaced_, not by wall-clock. Recorded here so the number is scoped against the protocol, not treated as a regression. diff --git a/docs/postmortems/2026-07-05-spike3-sd-cmd59.md b/docs/postmortems/2026-07-05-spike3-sd-cmd59.md index e224a0e..dd21d52 100644 --- a/docs/postmortems/2026-07-05-spike3-sd-cmd59.md +++ b/docs/postmortems/2026-07-05-spike3-sd-cmd59.md @@ -41,9 +41,10 @@ That unlink opens a crash window: between `f_unlink(target)` and `f_rename`, the target is momentarily gone while `*.tmp` holds the complete, fsync'd new content. The consequence for the real persistence module is a **boot-recovery** rule for a lingering `*.tmp`. Implementing it (`firmware::persistence::Storage::recover`) -surfaced that "promote the tmp" is too blunt: a crash *during* the tmp write (a +surfaced that "promote the tmp" is too blunt: a crash _during_ the tmp write (a second window, before the fsync) leaves a **partial** tmp, so recovery must look at whether the target survived — + - **tmp + target both present** → crash point ambiguous, tmp may be partial → keep the committed target, discard the tmp (the "previous version" guarantee); - **tmp only, target absent** → target was already unlinked → tmp is the newest @@ -74,12 +75,12 @@ genuine ≤32 GB card is on the bench. ## Bench wiring (shared SPI2, proven good) -| Signal | GPIO | Shared with EPD? | -| ------ | ---- | ---------------- | -| SCK | 12 | yes (epd.rs) | -| MOSI | 11 | yes (epd.rs) | +| Signal | GPIO | Shared with EPD? | +| ------ | ---- | ------------------------------------------------------------ | +| SCK | 12 | yes (epd.rs) | +| MOSI | 11 | yes (epd.rs) | | MISO | 13 | **no** — new line; the EPD is write-only and never used MISO | -| SD CS | 10 | no — EPD CS is 7 | +| SD CS | 10 | no — EPD CS is 7 | The SD sits on the same SPI2 bus as the panel, on its own chip-select. The spike runs **SD-only** (see "EPD bus lock" below). @@ -87,14 +88,14 @@ runs **SD-only** (see "EPD bus lock" below). ## Timeline 1. First flash → `sdmmc_send_cmd_crc_on_off returned 0x106`, `sdmmc_card_init - failed (0x106)`. Cryptic; could be wiring, pull-ups, speed, or card. +failed (0x106)`. Cryptic; could be wiring, pull-ups, speed, or card. 2. Added internal pull-ups on SCK/MOSI/MISO/CS and drove the EPD CS (GPIO 7) high (deselect the panel on the shared bus). **No change** — identical failure at the same command. A deterministic failure at one command argues against marginal signal integrity. 3. Verified the card on the Mac: `Windows_FAT_32 TYPOENA`, 33.6 GB partition on a 133 GB card, **healthy and readable**. Rules out a dead card / wrong FS. -4. Read the esp-idf SD init source: the CMD8 handler *tolerates* the +4. Read the esp-idf SD init source: the CMD8 handler _tolerates_ the same `ESP_ERR_NOT_SUPPORTED` (treats it as "not a v2 card"), while CMD59's does not. So the failure might be a persistent bad response, not a CMD59 one-off — needed the raw R1 bytes to tell. @@ -118,12 +119,12 @@ SD spec; some large/counterfeit SDXC cards don't implement it in SPI mode. Since esp-idf's `sdmmc_init_spi_crc` hard-fails when CMD59 fails (and there is **no** Kconfig or host flag to skip it), the mount aborts. -## What it was *not* +## What it was _not_ - **Not wiring / a swap** — CMD0/CMD8 responses are clean and correct. - **Not signal integrity** — internal pull-ups changed nothing; failure is deterministic at one command; init runs at 400 kHz where the jumpers are fine. -- **Not the filesystem** — failure is at SD *protocol* init, before any FAT +- **Not the filesystem** — failure is at SD _protocol_ init, before any FAT access. The card is FAT32 and mounts on the Mac. - **Not a level-shifter breakout** — it's a bare 3.3 V adapter. - **Not our FFI** — the hand-rolled `SDSPI_HOST_DEFAULT()` descriptors work; the @@ -143,7 +144,7 @@ message ("card rejected CMD59… use a genuine card, ideally ≤32 GB…") inste a raw code. This is the behavior the real `persistence` module should carry too: a bad card at boot should say "swap the SD," not hang on a hex code. -A CRC-off patch remains *possible* if bench work ever needs this exact card, but +A CRC-off patch remains _possible_ if bench work ever needs this exact card, but it is explicitly **not recommended** and not applied. ## Other findings worth keeping diff --git a/docs/postmortems/2026-07-05-spike7-gix-https-push.md b/docs/postmortems/2026-07-05-spike7-gix-https-push.md index cb2ea05..690e96d 100644 --- a/docs/postmortems/2026-07-05-spike7-gix-https-push.md +++ b/docs/postmortems/2026-07-05-spike7-gix-https-push.md @@ -14,9 +14,9 @@ ## Summary -Spike 7 was written as the kill-switch for [ADR-004](../adr.md): *"the +Spike 7 was written as the kill-switch for [ADR-004](../adr.md): _"the smart-HTTP path is validated in spike 7 before we commit to integration; if it -fails on the device, we fall back to `libgit2-sys`."* It never needed a device +fails on the device, we fall back to `libgit2-sys`."_ It never needed a device to fire. Before writing any gix code, gitoxide's own crate-status doc settles the question: `gix` has send-pack/receive-pack **plumbing** (report-status, sideband, delete-refs, atomic pushes) but supports push as a **workflow** only @@ -26,7 +26,7 @@ contrast, are robust over HTTP(S) — which is why Spike 6's TLS GET passed but does not carry over to push.) Because [ADR-005](../adr.md) fixes auth as **HTTPS + PAT**, `gix` cannot satisfy -the push path today. gix *can* push over `ssh://`, but that would (a) revisit +the push path today. gix _can_ push over `ssh://`, but that would (a) revisit ADR-005 and (b) still die on device — gix's SSH transport spawns the external `ssh` program, which does not exist on the ESP32. So the kill-switch condition is met at the library level. @@ -38,12 +38,12 @@ Proved the full `add → commit → push` sequence on desktop ## Why not the alternatives -| Option | Verdict | -| ------ | ------- | -| **gix + HTTPS** (as ADR-004 intended) | Blocked — gix has no HTTP(S) push. | -| **gix + SSH push** | gix supports it, but revisits ADR-005 *and* gix's SSH transport shells out to an `ssh` binary absent on ESP32 → dead on device. | +| Option | Verdict | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **gix + HTTPS** (as ADR-004 intended) | Blocked — gix has no HTTP(S) push. | +| **gix + SSH push** | gix supports it, but revisits ADR-005 _and_ gix's SSH transport shells out to an `ssh` binary absent on ESP32 → dead on device. | | **gix-protocol send-pack + custom HTTPS transport** | Pure-Rust, no ADR change, but not smoke-test-sized: hand-wiring send-pack over an mbedtls HTTP transport is real work and unproven upstream. Reconsider only if the libgit2 cross-compile (below) turns out worse. | -| **libgit2 (`git2`)** ← chosen | The ADR's named fallback. Trivial on desktop; the risk becomes the on-device cross-compile. | +| **libgit2 (`git2`)** ← chosen | The ADR's named fallback. Trivial on desktop; the risk becomes the on-device cross-compile. | ## What the desktop spike proves @@ -71,17 +71,17 @@ Implementation notes that carry into the real module: new + modified + **deleted** paths, unlike a naive `git add .`. v0.5 file-delete needs removals to reach the next Publish's staged set — this is that behavior. - **Push rejection is not always a `push()` error.** A non-fast-forward can come - back as a transport `Err` (local transport did this) *or* silently via the + back as a transport `Err` (local transport did this) _or_ silently via the `push_update_reference` callback with a status string while `push()` returns `Ok` (the HTTPS/GitHub path). The spike handles both and routes either to the pull-and-retry. The callback path is coded for but not yet exercised live. - **PAT hygiene.** The token is handed only to libgit2's credential callback (`Cred::userpass_plaintext`) and never logged — matches ADR-005. -## What it does *not* prove — the next gate +## What it does _not_ prove — the next gate The risk moved **with** the kill-switch and got harder. ADR-004 chose -gix *specifically to avoid* libgit2's C cross-compile to xtensa; falling back to +gix _specifically to avoid_ libgit2's C cross-compile to xtensa; falling back to libgit2 re-introduces exactly that. The open question is now: > Can `libgit2` (`git2` / `libgit2-sys`) cross-compile to @@ -99,7 +99,7 @@ is the on-device Spike 7 and it also depends on: `/sd/repo` working copy. So the full **SD → push** loop is still not testable on hardware; this spike -retired the *library/API* risk and replaced it with a *cross-compile* risk to +retired the _library/API_ risk and replaced it with a _cross-compile_ risk to tackle once PSRAM + SD are unblocked. ## On-device probe — 2026-07-05 @@ -107,7 +107,7 @@ tackle once PSRAM + SD are unblocked. Two moves toward the on-device gate, decoupled from the (still-blocked) SD card: **PSRAM enabled.** `sdkconfig.defaults` gained `CONFIG_SPIRAM=y` + -`CONFIG_SPIRAM_MODE_OCT=y` (the N16R8 is *octal* PSRAM — quad mode would fail +`CONFIG_SPIRAM_MODE_OCT=y` (the N16R8 is _octal_ PSRAM — quad mode would fail init) + `CONFIG_SPIRAM_USE_MALLOC=y` (adds PSRAM to the heap so large Rust allocs land there). Speed left at 40 MHz for a safe first enable. Octal PSRAM uses GPIO 33–37; the EPD/SD pins (4–13) avoid that range, so no wiring conflict. @@ -120,7 +120,7 @@ nothing. The ~1.5 MB git working-set budget now has headroom. **libgit2 cross-compile probe.** Added `git2` (default-features off → no openssl/ssh, isolating the C build from the TLS question) + a throwaway `git_probe` bin, and built for `xtensa-esp32s3-espidf`. Result reframes the risk -in a *more* encouraging direction than expected: +in a _more_ encouraging direction than expected: - **The C cross-compiles.** `xtensa-esp-elf-gcc` ran on libgit2's sources and several files built with `exit status: 0`. The feared "cmake can't target @@ -131,7 +131,7 @@ in a *more* encouraging direction than expected: vendored libgit2 as a **standalone `cc` library**, so it never inherits esp-idf's include paths — esp-idf's BSD-socket headers (lwIP) live under the esp-idf component tree, not the bare toolchain sysroot the `cc` invocation - used. (`arpa/inet.h` *does* exist in esp-idf, via lwIP's POSIX compat layer; + used. (`arpa/inet.h` _does_ exist in esp-idf, via lwIP's POSIX compat layer; it just wasn't on the `-I` path.) So the on-device libgit2 question is **not** "impossible," it's "needs esp-idf @@ -140,7 +140,7 @@ Candidate paths, roughly in order of effort: 1. **Inject esp-idf include dirs into the `cc` build** via `CFLAGS_xtensa-esp32s3-espidf` (point `-I` at esp-idf's lwIP POSIX-compat - headers). Cheapest to try; risk is a *cascade* — `arpa/inet.h` is likely the + headers). Cheapest to try; risk is a _cascade_ — `arpa/inet.h` is likely the first of several missing headers, and then missing lwIP/pthread symbols at final link. 2. **Build libgit2 as a proper esp-idf component** (CMake component pulled into @@ -157,7 +157,7 @@ backend), so path 1/2/3 is needed before even a transport-less build links. Injected esp-idf's lwIP + generated-config include dirs via `CFLAGS_xtensa_esp32s3_espidf` and rebuilt the probe. `arpa/inet.h` **resolved** — then the build immediately hit the next wall: `lwipopts.h → sys/ioctl.h: No -such file`, a header from a *different* esp-idf component (vfs/newlib), not lwIP. +such file`, a header from a _different_ esp-idf component (vfs/newlib), not lwIP. That is the whole problem in one line: **path 1 peels the esp-idf component include graph one component at a time**, with fragile absolute `-I` paths into build-output dirs (the config-dir hash even changed between two builds). It does @@ -167,7 +167,7 @@ include environment by hand. ### Decision: go straight to path 2 (libgit2 as an esp-idf component) Path 2 isn't just the robust include fix — it **solves the TLS backend at the -same time**. libgit2 the C library *does* support mbedTLS (`USE_HTTPS=mbedTLS`); +same time**. libgit2 the C library _does_ support mbedTLS (`USE_HTTPS=mbedTLS`); only the `libgit2-sys` Rust wrapper lacks it. Building libgit2 as an esp-idf CMake component lets us (a) inherit every component's includes + link (kills the cascade) and (b) set `USE_HTTPS=mbedTLS` against esp-idf's own mbedtls — which @@ -176,7 +176,7 @@ would make a **custom Rust subtransport unnecessary**. Two birds. Sketch: - Add libgit2 via esp-idf-sys's extra-components mechanism (a `components/` dir + `CMakeLists.txt` declaring `REQUIRES lwip mbedtls pthread newlib vfs`, wrapping libgit2's own CMake with `USE_HTTPS=mbedTLS`, `USE_SSH=OFF`). -- Bind to it from Rust — either `libgit2-sys` in *system* mode pointing at the +- Bind to it from Rust — either `libgit2-sys` in _system_ mode pointing at the component-built lib, or hand-rolled bindings for the handful of calls the `git` module needs. @@ -215,22 +215,22 @@ up. - [x] Settle the product sync transport — **DECIDED 2026-07-06: HTTPS + PAT** (on-device libgit2 is HTTPS-only; no libssh2 port). - [~] Retire the last shortcut: no PAT-in-flash — **decision documented, deferred** - as [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest) - (open): on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT. - Gates the first non-dev distribution; nothing to implement until then. + as [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest) + (open): on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT. + Gates the first non-dev distribution; nothing to implement until then. - [~] Fold the push into the editor's `git` module (persistent clone + - fast-forward) over HTTPS+PAT — **increment A DONE + hardware-verified - 2026-07-07** (`git_sync.rs`): `clone` + persistent `open` + fast-forward push - proven on device (`afa61de`), plus **recursive delete on FATFS solved** - (`8dece73`): the EACCES on wiping a repo dir was `std::fs::remove_dir_all` - (uses `openat`/`unlinkat`/`fdopendir`, unimplemented in esp-idf's path-based - FATFS VFS), NOT read-only — a diagnostic walk showed every entry writable - (`ro=false`). Fix = a path-based `remove_tree`; the `p_open`/`p_creat` shim - (`39e1155`) stays to keep objects writable. Remaining: **B** = divergence/ - merge path (merge commit on FATFS; low value for a single writer); **C** = - lift the logic into a reusable `git` module wired to the editor's `Ctrl-G`. - Storage caveat: the real notes repo is 3.9 GB / 562 MiB pack — needs - shallow+sparse or a dedicated small repo (ADR-007), can't clone whole. + fast-forward) over HTTPS+PAT — **increment A DONE + hardware-verified + 2026-07-07** (`git_sync.rs`): `clone` + persistent `open` + fast-forward push + proven on device (`afa61de`), plus **recursive delete on FATFS solved** + (`8dece73`): the EACCES on wiping a repo dir was `std::fs::remove_dir_all` + (uses `openat`/`unlinkat`/`fdopendir`, unimplemented in esp-idf's path-based + FATFS VFS), NOT read-only — a diagnostic walk showed every entry writable + (`ro=false`). Fix = a path-based `remove_tree`; the `p_open`/`p_creat` shim + (`39e1155`) stays to keep objects writable. Remaining: **B** = divergence/ + merge path (merge commit on FATFS; low value for a single writer); **C** = + lift the logic into a reusable `git` module wired to the editor's `Ctrl-G`. + Storage caveat: the real notes repo is 3.9 GB / 562 MiB pack — needs + shallow+sparse or a dedicated small repo (ADR-007), can't clone whole. - [x] Move git to a dedicated large-stack task so the shared main-task stack (and the editor build) can drop back — **DONE + hardware-verified 2026-07-06**. `git_publish` now runs on its own `std::thread` (`GIT_STACK = 96 KB` via @@ -260,12 +260,12 @@ and hashing reuse the mbedtls esp-idf already ships (and Spike 6 validated). **The port surface was small** — four shims, libgit2 sources untouched (so we never fork 1.9.4): -| Gap on esp-idf (picolibc + VFS) | Shim | -|---|---| -| no top-level `` (only ``) | forwarding `poll.h` on the include path | -| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` | -| `` absent | `esp_map.c` — `p_mmap` via `git__malloc` + `read` (pack pages land in PSRAM) | -| `getuid`/`geteuid`/`getgid`/`getppid`/`getpgid`/`getsid`/`getpwuid_r`/`readlink`/`utimes` declared but not implemented | `esp_stubs.c` — single-root-user, no-user-db, no-symlink answers | +| Gap on esp-idf (picolibc + VFS) | Shim | +| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| no top-level `` (only ``) | forwarding `poll.h` on the include path | +| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` | +| `` absent | `esp_map.c` — `p_mmap` via `git__malloc` + `read` (pack pages land in PSRAM) | +| `getuid`/`geteuid`/`getgid`/`getppid`/`getpgid`/`getsid`/`getpwuid_r`/`readlink`/`utimes` declared but not implemented | `esp_stubs.c` — single-root-user, no-user-db, no-symlink answers | Also: gcc 14 promoted `-Wimplicit-function-declaration` / `-Wincompatible-pointer-types` to hard errors; this pre-gcc14 C trips them @@ -290,7 +290,7 @@ mbedTLS HTTPS (needs Wi-Fi/SNTP from Spike 6 + a working-copy location). **Build mechanics learned.** The component is wired via `[[package.metadata.esp-idf-sys.extra_components]]` `component_dirs`, pointed at on-disk source through a `LIBGIT2_SRC` env var (probe stage — not yet vendored). -esp-idf-sys emits no `rerun-if-*`, so editing the *root* Cargo.toml or the +esp-idf-sys emits no `rerun-if-*`, so editing the _root_ Cargo.toml or the component doesn't retrigger its build script once it has succeeded; forcing a reconfigure means `rm -rf target/**/.fingerprint/esp-idf-sys-*` (cheap — the 159 MB cmake cache in the OUT_DIR persists, so only the changed component @@ -300,7 +300,7 @@ recompiles). externs with the real path: the `git2` crate (default-features off, so no openssl-sys/libssh2-sys) bound to our component via `libgit2-sys` in **system mode** (`LIBGIT2_NO_VENDOR=1`). The trick: we don't want libgit2-sys to build -*or* link anything — esp-idf already links `liblibgit2.a` inside its component +_or_ link anything — esp-idf already links `liblibgit2.a` inside its component group (verified in `build.ninja`: `esp-idf/libgit2/liblibgit2.a` sits in the `libespidf.elf` `LINK_LIBRARIES`, and the group is repeated ~6× so libgit2's refs to mbedtls/lwip resolve). So a **fake pkg-config with empty `Libs`** @@ -319,7 +319,7 @@ target/**/.fingerprint/esp-idf-sys-*` before building forces a fresh forward **Build-gating done:** `git2` is an optional dep behind the `git` feature, and `git_smoke` has `required-features = ["git"]`, so the editor build never pulls -libgit2-sys/pkg-config. The component's CMake now registers *empty* when +libgit2-sys/pkg-config. The component's CMake now registers _empty_ when `LIBGIT2_SRC` is unset, so `just build` (no env) still works. **Open decisions before commit** (deliberately not done yet): @@ -332,7 +332,7 @@ libgit2-sys/pkg-config. The component's CMake now registers *empty* when is already gated). Accept, or gate the C compile too. 3. ~~Runtime (Gate D on HW)~~ — **DONE 2026-07-05.** `just flash-git` on the S3: `git2 crate is talking to libgit2 1.9.4`, then `sha1(blob "hello") = - b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0` + "hash matches" — i.e. git2 → +b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0` + "hash matches" — i.e. git2 → libgit2 → mbedTLS SHA1 all ran correctly on device. Full chain proven. 4. ~~**The real thing** — `repository_init` → `commit` → `push` over mbedTLS HTTPS~~ — **DONE + hardware-verified 2026-07-06** (flash-FAT working copy); @@ -362,9 +362,10 @@ flash-FAT → `repository_init` → `add_all` → `commit` → smart-HTTP push + upload + PAT auth all work on-device through libgit2 + esp-idf mbedTLS. **Heap:** started at 8.44 MB free, min-ever **6.85 MB** — the whole TLS handshake -+ packfile build cost ~1.6 MB, all served from PSRAM; internal DRAM was never -stressed. **Timing:** first-boot FATFS format of the working-copy dir ~7.7 s, -commit sub-second, TLS handshake→accept ~6 s. + +- packfile build cost ~1.6 MB, all served from PSRAM; internal DRAM was never + stressed. **Timing:** first-boot FATFS format of the working-copy dir ~7.7 s, + commit sub-second, TLS handshake→accept ~6 s. ### Three bugs stood between "links + runs" and "pushes" @@ -375,11 +376,11 @@ microcommits through `a15789a`). 1. **Main task stack 12 KB → 96 KB.** libgit2 is stack-hungry: nearly every function puts a `char path[GIT_PATH_MAX]` (4 KB) buffer on the stack, and the `repository_init → config-write → FATFS → wear-leveling` chain nests ~10 of - them — a *trivial config write* measured ~67 KB of stack. At 48 KB it + them — a _trivial config write_ measured ~67 KB of stack. At 48 KB it overflowed and smashed an adjacent newlib lock handle → `LoadProhibited` in `xQueueGenericSend`. **This corrected an earlier misdiagnosis:** the "`time()` only works on the main task, not a std::thread" conclusion from the first - on-device attempt was wrong — that thread had the *default 4 KB* stack, so the + on-device attempt was wrong — that thread had the _default 4 KB_ stack, so the same deep chain just overflowed sooner. It was always stack depth, not thread-vs-main. (This stack has since moved: `sdkconfig.defaults` is shared with the editor build, so git was later given its OWN 96 KB `std::thread` and @@ -399,7 +400,7 @@ microcommits through `a15789a`). 3. **`utimes` existence-gate — the killer.** This one silently defeated every object write. Our first `utimes` stub returned `0` unconditionally ("VFS can't set times; ignore"). But libgit2's `git_futils_touch()` → `p_utimes()` is how - the loose ODB's `freshen` probe answers *"does this object already exist?"*, + the loose ODB's `freshen` probe answers _"does this object already exist?"_, and `git_odb_write()` (`odb.c:1629`) **skips the write entirely** when freshen succeeds. So a blanket `return 0` made freshen always report "exists" → libgit2 believed every object was already on disk → **every** blob/tree/commit @@ -411,7 +412,7 @@ microcommits through `a15789a`). Diagnosed with an in-binary A/B/C/D ODB probe — write an in-memory blob, a file blob, run `add_all`, then walk `.git/objects` — which showed `exists = - false` for every OID and an empty objects dir, isolating it to the *write* +false` for every OID and an empty objects dir, isolating it to the _write_ path (not read, not mmap, not the index). The vendored `odb.c` / `odb_loose.c` / `futils.c` source then pinned it to the freshen→touch→utimes chain. **Lesson:** a "harmless" no-op POSIX stub is actively dangerous when a diff --git a/docs/postmortems/2026-07-11-editor-freeze-spi-dma-oom-during-sync.md b/docs/postmortems/2026-07-11-editor-freeze-spi-dma-oom-during-sync.md index 7ada9b0..9f1b4f9 100644 --- a/docs/postmortems/2026-07-11-editor-freeze-spi-dma-oom-during-sync.md +++ b/docs/postmortems/2026-07-11-editor-freeze-spi-dma-oom-during-sync.md @@ -85,9 +85,9 @@ Two things made this easy to misread: 5. Push completes normally; Wi-Fi torn down; internal RAM freed — but the editor task is already gone, so nothing repaints. Keys log into the void. -## What it was *not* +## What it was _not_ -- **Not out of heap** — plenty of PSRAM free; it was internal *DMA-capable* RAM +- **Not out of heap** — plenty of PSRAM free; it was internal _DMA-capable_ RAM specifically. - **Not a git or TLS bug** — the sync succeeded end to end. - **Not an editor-core bug** — `editor`/`keymap` never ran; the failure is in the @@ -141,18 +141,18 @@ against the ~630 ms panel waveform). **Alternatives considered and rejected:** -- *Trim Wi-Fi's internal buffer counts* to leave headroom — fragile tuning that +- _Trim Wi-Fi's internal buffer counts_ to leave headroom — fragile tuning that risks Wi-Fi stability/throughput and only widens the margin instead of removing the failure mode. -- *Force the whole ~13.6 KB frame buffer internal* (custom allocator / full +- _Force the whole ~13.6 KB frame buffer internal_ (custom allocator / full `heap_caps` buffer) — larger reservation and more churn than the 4 KB chunk-scratch, for no extra benefit since `data()` already chunks. -- *Serialize paints against sync* (skip painting while a push is in flight) — +- _Serialize paints against sync_ (skip painting while a push is in flight) — defeats the async-git design and freezes the panel for the whole push; the safety net already makes this unnecessary. **Verification when built:** reproduce the exact failing scenario — edit, -`:sync`, and keep scrolling/paging through the *entire* push. Expect: every +`:sync`, and keep scrolling/paging through the _entire_ push. Expect: every refresh succeeds (no `refresh … FAILED` warnings, no dropped frames), min-ever internal heap stays comfortably above zero throughout, and `force_full` is not triggered by the sync. @@ -170,5 +170,5 @@ triggered by the sync. - [ ] Implement the persistent internal DMA scratch buffer in `Epd` (eradication above) if the stale-during-sync window proves annoying in real use — → tracked in [v0.9 robustness](../v0.9-robustness.md). -- [ ] After eradication, confirm refreshes succeed *during* a push and drop the +- [ ] After eradication, confirm refreshes succeed _during_ a push and drop the stale window entirely. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index 31d18d0..a5a3e7f 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -2,14 +2,14 @@ > Bench + bring-up debugging write-ups: what broke, how we found the root cause, > and the decisions that came out of it. One file per incident, named -> `YYYY-MM-DD-.md`. These capture *why* a spike stalled or a design turned +> `YYYY-MM-DD-.md`. These capture _why_ a spike stalled or a design turned > — the kind of context that's expensive to reconstruct later. > > Project overview: [`../../README.md`](../../README.md). Bring-up spikes: > [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order). -| Date | Incident | Status | -| ---------- | ------------------------------------------------------------------------ | ------ | -| 2026-07-05 | [Spike 3 (SD) — card rejects CMD59 (SPI-mode CRC)](2026-07-05-spike3-sd-cmd59.md) | Paused — awaiting a compliant microSD; wiring + firmware proven | -| 2026-07-05 | [Spike 7 (git push) — ADR-004 kill-switch fired: gix can't push over HTTPS](2026-07-05-spike7-gix-https-push.md) | Turned — pivoted to libgit2; git mechanics proven on desktop, device build next | -| 2026-07-11 | [Editor freeze — SPI-DMA OOM during a background `:sync`](2026-07-11-editor-freeze-spi-dma-oom-during-sync.md) | Safety net shipped (paints non-fatal); root-cause eradication specced, not yet built | +| Date | Incident | Status | +| ---------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| 2026-07-05 | [Spike 3 (SD) — card rejects CMD59 (SPI-mode CRC)](2026-07-05-spike3-sd-cmd59.md) | Paused — awaiting a compliant microSD; wiring + firmware proven | +| 2026-07-05 | [Spike 7 (git push) — ADR-004 kill-switch fired: gix can't push over HTTPS](2026-07-05-spike7-gix-https-push.md) | Turned — pivoted to libgit2; git mechanics proven on desktop, device build next | +| 2026-07-11 | [Editor freeze — SPI-DMA OOM during a background `:sync`](2026-07-11-editor-freeze-spi-dma-oom-during-sync.md) | Safety net shipped (paints non-fatal); root-cause eradication specced, not yet built | diff --git a/docs/qfd.md b/docs/qfd.md index 45e2581..fbdd144 100644 --- a/docs/qfd.md +++ b/docs/qfd.md @@ -587,18 +587,18 @@ targets kept; relations + roof + Σ basement left empty for practice), see What a user (= me) values about the device, with importance weights on a 1–10 scale. Source columns point at the doc the requirement comes from. -| ID | Requirement | Weight | Source | -| --- | ---------------------------------------------------------------------------------- | :----: | ------------------------------------------------------------------------------------------------------------------------------ | -| W1 | Sub-second visible response to typing | 10 | [product → Write](v0.1-mvp-product.md#user-stories), [README → UX](../README.md#ux-boundaries-set-by-the-medium) | -| W2 | **Publishing** is one deliberate action away | 9 | [product → Publish](v0.1-mvp-product.md#user-stories), [CONTEXT → Publish](../CONTEXT.md#user-facing-actions) | -| W3 | Pulling power never corrupts the file | 10 | [product → Recover](v0.1-mvp-product.md#user-stories), [acceptance](v0.1-mvp-product.md#acceptance-criteria) | +| ID | Requirement | Weight | Source | +| --- | ---------------------------------------------------------------------------------- | :----: | ---------------------------------------------------------------------------------------------------------------------------------- | +| W1 | Sub-second visible response to typing | 10 | [product → Write](v0.1-mvp-product.md#user-stories), [README → UX](../README.md#ux-boundaries-set-by-the-medium) | +| W2 | **Publishing** is one deliberate action away | 9 | [product → Publish](v0.1-mvp-product.md#user-stories), [CONTEXT → Publish](../CONTEXT.md#user-facing-actions) | +| W3 | Pulling power never corrupts the file | 10 | [product → Recover](v0.1-mvp-product.md#user-stories), [acceptance](v0.1-mvp-product.md#acceptance-criteria) | | W4 | Provisioning never interrupts a writing session | 7 | [product → Provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only), [macroplan → v0.9](macroplan.md#v09--robustness--) | -| W5 | Quick boot to a writing cursor | 6 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (≤ 5 s) | -| W6 | Long sessions without crash / lag / drift | 9 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (1 h soak) | -| W7 | Nothing on the device competes with prose | 8 | [README → vision](../README.md#vision) | -| W8 | The UI never moves except when I move it | 7 | [README → UX](../README.md#ux-boundaries-set-by-the-medium) | +| W5 | Quick boot to a writing cursor | 6 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (≤ 5 s) | +| W6 | Long sessions without crash / lag / drift | 9 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (1 h soak) | +| W7 | Nothing on the device competes with prose | 8 | [README → vision](../README.md#vision) | +| W8 | The UI never moves except when I move it | 7 | [README → UX](../README.md#ux-boundaries-set-by-the-medium) | | W9 | Codebase absorbs the planned roadmap without rewrite | 8 | [macroplan](macroplan.md) | -| W10 | I can repair or fork it with hobbyist tools | 5 | [README → vision](../README.md#vision) | +| W10 | I can repair or fork it with hobbyist tools | 5 | [README → vision](../README.md#vision) | | W11 | Multi-day battery life (v0.8 onward) | 4 | [macroplan → v0.8](macroplan.md#v08--power-battery--sleep--) | | W12 | Local-only file scope coexists with git scope (v0.5+) | 5 | [README → scopes](../README.md#vision), [macroplan → v0.5](macroplan.md#v05--file-palette--multi-file--) | | W13 | Typography sets a writing-tool tone — typewriter or developer editor, never gadget | 7 | [macroplan → v1.0](macroplan.md), [README → UX](../README.md#ux-boundaries-set-by-the-medium) | @@ -620,14 +620,14 @@ unless noted. Direction column shows what "better" looks like The device performs these. The HOW rows below measure quality attributes of these functions, or of artifacts they produce. -| Function | Transformation | -| --------- | ----------------------------------------------- | -| Type | keypress → glyph rendered + buffer mutated | -| Save | dirty buffer → persisted file on SD | -| Publish | persisted file → commit on remote | -| Recover | degraded file state → readable file | -| Boot | power-on → cursor ready | -| Provision | uninitialized device → configured device | +| Function | Transformation | +| --------- | ------------------------------------------ | +| Type | keypress → glyph rendered + buffer mutated | +| Save | dirty buffer → persisted file on SD | +| Publish | persisted file → commit on remote | +| Recover | degraded file state → readable file | +| Boot | power-on → cursor ready | +| Provision | uninitialized device → configured device | **Provision** is build-time-only in v0.1 ([ADR-005], [ADR-007]); it joins the runtime five from v0.9 onward. Sub-functions referenced @@ -636,22 +636,22 @@ inside HOW names: **Render** (buffer → e-ink frame, inside Type), ### Characteristics -| ID | Characteristic | Dir | v0.1 target | v1.0 target | -| --- | -------------------------------------------------- | :-: | ------------------------ | ------------------- | -| H1 | Type latency (keypress → glyph) | ↓ | ≤ 400 ms § | ≤ 300 ms § | -| H2 | Partial-refresh region area per keystroke | ↓ | ≤ 1 text line (~22 px h) | same | -| H3 | Full-refresh cadence (clears ghosting) | → | 1 per 64 partials | tuned by panel temp | -| H4 | Boot latency (cold) | ↓ | ≤ 5 s | ≤ 3 s † | -| H5 | Continuous-typing endurance (no drop, no leak) | ↑ | ≥ 1 h | ≥ 8 h | -| H6 | Publish reliability (network up) | ↑ | ≥ 95 % | ≥ 99 % | -| H7 | Publish latency (one file) | ↓ | ≤ 30 s ‡ | ≤ 10 s ‡ | -| H8 | Save durability (post-confirm power loss) | → | 100 % | 100 % | -| H9 | PSRAM heap headroom during Publish | ↑ | ≥ 1 MB free at peak | same | -| H10 | Firmware binary size | ↓ | ≤ 2 MB | ≤ 1.5 MB | -| H11 | Stack budget across all tasks | ↓ | ≤ 80 KB (sum) | same | -| H12 | Network reconnect time (transient outage) | ↓ | ≤ 30 s | ≤ 10 s | -| H13 | Idle / typing / Publish current draw | ↓ | measured only | sized for >2 days | -| H15 | Build time (clean, release) | ↓ | ≤ 7 min | ≤ 5 min | +| ID | Characteristic | Dir | v0.1 target | v1.0 target | +| --- | ---------------------------------------------- | :-: | ------------------------ | ------------------- | +| H1 | Type latency (keypress → glyph) | ↓ | ≤ 400 ms § | ≤ 300 ms § | +| H2 | Partial-refresh region area per keystroke | ↓ | ≤ 1 text line (~22 px h) | same | +| H3 | Full-refresh cadence (clears ghosting) | → | 1 per 64 partials | tuned by panel temp | +| H4 | Boot latency (cold) | ↓ | ≤ 5 s | ≤ 3 s † | +| H5 | Continuous-typing endurance (no drop, no leak) | ↑ | ≥ 1 h | ≥ 8 h | +| H6 | Publish reliability (network up) | ↑ | ≥ 95 % | ≥ 99 % | +| H7 | Publish latency (one file) | ↓ | ≤ 30 s ‡ | ≤ 10 s ‡ | +| H8 | Save durability (post-confirm power loss) | → | 100 % | 100 % | +| H9 | PSRAM heap headroom during Publish | ↑ | ≥ 1 MB free at peak | same | +| H10 | Firmware binary size | ↓ | ≤ 2 MB | ≤ 1.5 MB | +| H11 | Stack budget across all tasks | ↓ | ≤ 80 KB (sum) | same | +| H12 | Network reconnect time (transient outage) | ↓ | ≤ 30 s | ≤ 10 s | +| H13 | Idle / typing / Publish current draw | ↓ | measured only | sized for >2 days | +| H15 | Build time (clean, release) | ↓ | ≤ 7 min | ≤ 5 min | † **Boot latency, measured 2026-07-11:** cold boot is **4258 ms**, so the ≤ 5 s v0.1 target is met. The ≤ 3 s v1.0 target is assessed **marginal-to-unreachable** — @@ -663,9 +663,10 @@ Breakdown + levers: [`notes/boot-time-budget.md`](notes/boot-time-budget.md). **~10 s**), comfortably inside the ≤ 30 s v0.1 target. The ≤ 10 s v1.0 target is **marginal** — the warm path meets it, but a cold sync's one-time Wi-Fi assoc (~3.6 s) + SNTP (~2–4 s) push it over, and the transport itself (one TLS handshake -+ commit + push) is near its floor. Optimistic-retry (push onto the tip first, -reconcile only on a rejected push) already cut a whole second handshake. Breakdown -+ levers: [`notes/sync-latency.md`](notes/sync-latency.md). + +- commit + push) is near its floor. Optimistic-retry (push onto the tip first, + reconcile only on a rejected push) already cut a whole second handshake. Breakdown +- levers: [`notes/sync-latency.md`](notes/sync-latency.md). § **Type latency — revised target, measured 2026-07-11.** Cold per-keystroke render (keypress → glyph settled) measures **~630 ms**, so the v0.1 target is @@ -777,22 +778,22 @@ number), W9 both 1→2 (Rust rewrite explicitly unblocked features that JS could not carry; still closed so neither reaches reMarkable's hackable-Linux 3). -| ID | WHAT (truncated) | Typoena | reM. | Frw.T | Frw.S | Pom. | Rationale (shortest defensible) | -| --- | ------------------------------------------------- | :-----: | :--: | :---: | :---: | :--: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ID | WHAT (truncated) | Typoena | reM. | Frw.T | Frw.S | Pom. | Rationale (shortest defensible) | +| --- | ------------------------------------------------- | :-----: | :--: | :---: | :---: | :--: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | W1 | Sub-second response to typing | 2 | 1 | 4 | 3 | 5 | Typoena type latency measured ~630 ms (2026-07-11) — a visible per-keystroke lag, sub-second but well behind the Freewrites' ~200 ms (v0.1 H1 target relaxed ≤200→≤400 ms, still unmet), so 2 not 4; reMarkable e-ink visibly laggy on a typing-focused device — tested less responsive than Smart Typewriter, and latency is so load-bearing for W1 that it earns a 1 not a 2; both Freewrites post-Sailfish trimmed latency 40–100 % (Frw.T plausibly inside 200 ms; Frw.S still trails by one notch on larger panel); Pomera LCD ~zero. | -| W2 | Publishing is one deliberate action away | 5 | 4 | 4 | 4 | 2 | Ctrl-G atomic; reMarkable + Freewrite cloud-sync is one-tap but not git; Pomera = USB/SD copy or QR transfer. | -| W3 | Pulling power never corrupts the file | 4 | 4 | 2 | 2 | 2 | Typoena: atomic-rename + fsync (round-trip verified 2026-07-11; power-pull test deferred to v0.9). reMarkable journals. Freewrite + Pomera: forum reports of corruption on yank. | -| W4 | Provisioning never interrupts writing | 5 | 2 | 2 | 2 | 5 | Typoena v0.1: build-time config (dev-only). reM/Frw need Wi-Fi + account. Pomera: literally none. | -| W5 | Quick boot to a writing cursor | 4 | 3 | 4 | 4 | 5 | Typoena measured 4.26 s cold (2026-07-11). reMarkable cold-boots ~20 s (great from sleep). Both Freewrites accelerated post-Sailfish (no published number; were ~10–15 s e-ink wake). Pomera ~3 s. | -| W6 | Long sessions without crash / lag / drift | 4 | 3 | 4 | 4 | 5 | Typoena: 1 h soak attested 2026-07-11 (real use, no crash / lag / leak) — one proven hour vs rivals' years, so 4 not 5. Freewrite famously stable (both variants). Pomera firmware is decades-mature. | -| W7 | Nothing on the device competes with prose | 5 | 2 | 5 | 5 | 5 | reMarkable has apps, menus, drawing, PDFs. Freewrite + Pomera are single-purpose; Typoena by design. | -| W8 | The UI never moves except when I move it | 4 | 3 | 4 | 4 | 5 | reMarkable animates more; Typoena uses dirty-rects; Freewrites minimal motion; Pomera near-static LCD. | -| W9 | Codebase absorbs the planned roadmap | 4 | 3 | 2 | 2 | 1 | Modular Rust Typoena; reMarkable is hackable Linux; both Freewrites carry Sailfish (Rust rewrite explicitly unblocked features JS could not carry) but closed; Pomera closed firmware. | -| W10 | I can repair or fork it with hobbyist tools | 5 | 4 | 2 | 2 | 1 | Typoena: open BOM + ESP32. reMarkable: rooted Linux + community ROMs. Freewrite + Pomera: closed. | -| W11 | Multi-day battery life (v0.8 onward) | 1 | 5 | 5 | 5 | 4 | Typoena v0.1 = wall-powered (battery deferred). reMarkable + both Freewrites legendary (~4 weeks; Sailfish trimmed −30 % typing / −50 % idle). Pomera ~24 h. | -| W12 | Local-only files coexist with git scope | 3 | 1 | 2 | 2 | 3 | Typoena v0.5+ design. reMarkable cloud-only. Freewrites have local + Postbox but no VCS. Pomera = pure local. | -| W13 | Typography sets a writing-tool tone | 3 | 5 | 2 | 2 | 2 | Typoena v0.1: single mono (serif option in v1.0). reMarkable: rich type rendering. Freewrite + Pomera: utilitarian. | -| W14 | I can carry the device and write away from a desk | 2 | 4 | 5 | 1 | 5 | Typoena v0.1 wall-powered (ADR-008), no enclosure spec yet — desk-bound by design. reMarkable + Type Folio bag-friendly with bulk. Freewrite Traveler is the form-factor reference (~1.6 lb, folds). Smart Typewriter ~5 lb, desk-bound. Pomera DM250 pocketable foldable. | +| W2 | Publishing is one deliberate action away | 5 | 4 | 4 | 4 | 2 | Ctrl-G atomic; reMarkable + Freewrite cloud-sync is one-tap but not git; Pomera = USB/SD copy or QR transfer. | +| W3 | Pulling power never corrupts the file | 4 | 4 | 2 | 2 | 2 | Typoena: atomic-rename + fsync (round-trip verified 2026-07-11; power-pull test deferred to v0.9). reMarkable journals. Freewrite + Pomera: forum reports of corruption on yank. | +| W4 | Provisioning never interrupts writing | 5 | 2 | 2 | 2 | 5 | Typoena v0.1: build-time config (dev-only). reM/Frw need Wi-Fi + account. Pomera: literally none. | +| W5 | Quick boot to a writing cursor | 4 | 3 | 4 | 4 | 5 | Typoena measured 4.26 s cold (2026-07-11). reMarkable cold-boots ~20 s (great from sleep). Both Freewrites accelerated post-Sailfish (no published number; were ~10–15 s e-ink wake). Pomera ~3 s. | +| W6 | Long sessions without crash / lag / drift | 4 | 3 | 4 | 4 | 5 | Typoena: 1 h soak attested 2026-07-11 (real use, no crash / lag / leak) — one proven hour vs rivals' years, so 4 not 5. Freewrite famously stable (both variants). Pomera firmware is decades-mature. | +| W7 | Nothing on the device competes with prose | 5 | 2 | 5 | 5 | 5 | reMarkable has apps, menus, drawing, PDFs. Freewrite + Pomera are single-purpose; Typoena by design. | +| W8 | The UI never moves except when I move it | 4 | 3 | 4 | 4 | 5 | reMarkable animates more; Typoena uses dirty-rects; Freewrites minimal motion; Pomera near-static LCD. | +| W9 | Codebase absorbs the planned roadmap | 4 | 3 | 2 | 2 | 1 | Modular Rust Typoena; reMarkable is hackable Linux; both Freewrites carry Sailfish (Rust rewrite explicitly unblocked features JS could not carry) but closed; Pomera closed firmware. | +| W10 | I can repair or fork it with hobbyist tools | 5 | 4 | 2 | 2 | 1 | Typoena: open BOM + ESP32. reMarkable: rooted Linux + community ROMs. Freewrite + Pomera: closed. | +| W11 | Multi-day battery life (v0.8 onward) | 1 | 5 | 5 | 5 | 4 | Typoena v0.1 = wall-powered (battery deferred). reMarkable + both Freewrites legendary (~4 weeks; Sailfish trimmed −30 % typing / −50 % idle). Pomera ~24 h. | +| W12 | Local-only files coexist with git scope | 3 | 1 | 2 | 2 | 3 | Typoena v0.5+ design. reMarkable cloud-only. Freewrites have local + Postbox but no VCS. Pomera = pure local. | +| W13 | Typography sets a writing-tool tone | 3 | 5 | 2 | 2 | 2 | Typoena v0.1: single mono (serif option in v1.0). reMarkable: rich type rendering. Freewrite + Pomera: utilitarian. | +| W14 | I can carry the device and write away from a desk | 2 | 4 | 5 | 1 | 5 | Typoena v0.1 wall-powered (ADR-008), no enclosure spec yet — desk-bound by design. reMarkable + Type Folio bag-friendly with bulk. Freewrite Traveler is the form-factor reference (~1.6 lb, folds). Smart Typewriter ~5 lb, desk-bound. Pomera DM250 pocketable foldable. | **Totals** (sum across 14 WHATs, no weighting): Typoena 51, Pomera 50, Freewrite Traveler 47, reMarkable 44, Freewrite Smart Typewriter 42 @@ -984,16 +985,16 @@ also lifted H8 durability over its narrow voter base; W14 has widened that base, so H8's #3 spot is now arithmetic — see §3.) These are the numbers spikes 2–7 must validate before integration starts. -| Rank | Characteristic | Target | Watched on | If we miss it | -| ---- | -------------- | ------------------------------------- | ----------------- | ------------------------------------------------------------------------- | -| 1 | H2 region area | ≤ 1 line per keypress | spike 2 + spike 5 | Increase font size to shrink per-glyph dirty rect ([ADR-003] consequence) | -| 2 | H9 PSRAM heap | ≥ 1 MB free at push peak | spike 7 | [ADR-004] kill-switch → `libgit2-sys`; cap rope at 128 KB | -| 3 | H8 durability | 100 % (post-confirm power loss) | bench HIL | Re-evaluate [ADR-007] (move config to internal NVS only) | -| 4 | H1 Type latency | ≤ 400 ms (revised from ≤ 200 ms) | ~630 ms 2026-07-11 ✗ | Still over target — windowed-Y refresh already in; batch multi-char bursts; open v0.1 gap | -| 5 | H6 Publish reliability | ≥ 95 % (network up) | spike 6 + spike 7 | TLS cipher trim; reconnect backoff tuning | -| 6 | H3 cadence | full every ~64 partials | spike 2 | Adjust per panel temperature; defer flash to idle ≥ 1 s | -| 7 | H4 Boot latency | ≤ 5 s (cold, to cursor) | 4258 ms 2026-07-11 ✓ | Editor rides a full-area partial over the splash (done, −1.25 s); PSRAM memtest off (−0.74 s) — [boot-time-budget](notes/boot-time-budget.md) | -| 8 | H5 soak | 1 h no leak / no drop | 1 h bench soak | Glyph-cache eviction; PSRAM heap-fragmentation review | +| Rank | Characteristic | Target | Watched on | If we miss it | +| ---- | ---------------------- | -------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | H2 region area | ≤ 1 line per keypress | spike 2 + spike 5 | Increase font size to shrink per-glyph dirty rect ([ADR-003] consequence) | +| 2 | H9 PSRAM heap | ≥ 1 MB free at push peak | spike 7 | [ADR-004] kill-switch → `libgit2-sys`; cap rope at 128 KB | +| 3 | H8 durability | 100 % (post-confirm power loss) | bench HIL | Re-evaluate [ADR-007] (move config to internal NVS only) | +| 4 | H1 Type latency | ≤ 400 ms (revised from ≤ 200 ms) | ~630 ms 2026-07-11 ✗ | Still over target — windowed-Y refresh already in; batch multi-char bursts; open v0.1 gap | +| 5 | H6 Publish reliability | ≥ 95 % (network up) | spike 6 + spike 7 | TLS cipher trim; reconnect backoff tuning | +| 6 | H3 cadence | full every ~64 partials | spike 2 | Adjust per panel temperature; defer flash to idle ≥ 1 s | +| 7 | H4 Boot latency | ≤ 5 s (cold, to cursor) | 4258 ms 2026-07-11 ✓ | Editor rides a full-area partial over the splash (done, −1.25 s); PSRAM memtest off (−0.74 s) — [boot-time-budget](notes/boot-time-budget.md) | +| 8 | H5 soak | 1 h no leak / no drop | 1 h bench soak | Glyph-cache eviction; PSRAM heap-fragmentation review | The two not-in-MVP rows but already-shaped-by-design: @@ -1133,7 +1134,7 @@ These are the live tensions we are watching, not deciding harder: basement. - **HOWs renamed "characteristics," not "functions."** A function is a transformation (input → output); HOWs like H6 "success rate" and - H10 "binary size" are *measures* of functions or properties of + H10 "binary size" are _measures_ of functions or properties of artifacts, not transformations themselves. §2's header, §4's ("HOW-vs-HOW tradeoffs"), §5's ("HOW → Component mapping") and caption, and §6's column header all cascaded — wherever "function" diff --git a/docs/spikes.md b/docs/spikes.md index 6a15ae3..bc287f7 100644 --- a/docs/spikes.md +++ b/docs/spikes.md @@ -17,15 +17,15 @@ line-number gutter, status readouts) draw within and around. Each feeds a different release, but the bench experiment is worth running now to retire the risk early. -| Spike | Feeds | -| ----------------------------------------- | ---------------------------------------------------- | -| 8 — Layout: side panel + writing column | v0.1 | -| 9 — Boot splash bitmap | v0.1 | -| 10 — Dark / light theme | v1.0 | -| 11 — Transient panel (help / config) | v0.4 `:` · v0.5 palette · v0.9 settings (mechanism) | -| 12 — Scroll position indicator | reading / `View`-mode UX | -| 13 — Line-number gutter | v0.2 | -| 14 — Multi-file navigation | v0.5 | +| Spike | Feeds | +| --------------------------------------- | --------------------------------------------------- | +| 8 — Layout: side panel + writing column | v0.1 | +| 9 — Boot splash bitmap | v0.1 | +| 10 — Dark / light theme | v1.0 | +| 11 — Transient panel (help / config) | v0.4 `:` · v0.5 palette · v0.9 settings (mechanism) | +| 12 — Scroll position indicator | reading / `View`-mode UX | +| 13 — Line-number gutter | v0.2 | +| 14 — Multi-file navigation | v0.5 | 8. **Spike 8 — Layout: side panel + writing column.** Split the 792×272 landscape into a full-height **writing column** (~60 cols, left) and an always-visible @@ -35,18 +35,18 @@ risk early. ([`firmware/src/editor.rs`](../firmware/src/editor.rs), `COLS`/`ROWS` + `draw_status`); this spike narrows the text to a legible measure and moves all metadata into the panel, retiring the header/status bands entirely. Two driver - facts shape it. First, narrowing does *not* speed up typing: `update_part` + facts shape it. First, narrowing does _not_ speed up typing: `update_part` already drives both controllers full width, windowed only in Y, because "the waveform time dominates, not the data clock-out" ([`firmware/src/epd.rs`](../firmware/src/epd.rs)) — the win is line length and persistent info, not latency. Second, since every partial refresh spans the full width, a keystroke's windowed-Y band repaints the panel's pixels on that row - *for free* (redrawn identically), but a panel field that changes on a *different* + _for free_ (redrawn identically), but a panel field that changes on a _different_ row than the cursor costs a **second** windowed-Y band — burning the "20 partials → forced full refresh" ghosting counter twice as fast (render module). That sorts what the panel may hold: static (filename, dirty), event-driven (mode, Wi-Fi, keyboard-disconnect, publish state), and throttled (clock, word count, session) - fields only — nothing that repaints per keystroke, so no live cursor *column*. + fields only — nothing that repaints per keystroke, so no live cursor _column_. The panel sits entirely in the master half (right of the `x = 396` seam), so its glyphs never split the seam; the writing column still straddles it, as today. Prove: render column + panel; type and confirm the windowed-Y refresh stays a @@ -64,7 +64,7 @@ risk early. clean full refresh. Mostly a feature — kept as a spike only to prove the asset path. (Feeds v0.1's "e-ink shows Typoena splash + boot log".) - **Built 2026-07-11 as a *vector* splash**, not a bitmap. The frame is + **Built 2026-07-11 as a _vector_ splash**, not a bitmap. The frame is [`display::Frame::splash`](../display/src/lib.rs) — the `typoena` wordmark centred inside a stroked `Circle`, drawn with `embedded-graphics`, one clean full refresh. It is shared by two callers: the @@ -98,12 +98,12 @@ risk early. help screen (trivial content): a full refresh on enter and on exit restores the prior text exactly; measure in/out latency. Decides overlay-box vs. full-screen swap — recommend the swap, since a dimmed partial overlay is both - slow and ghost-prone at ~630 ms. Panel *content* (which config keys, help + slow and ghost-prone at ~630 ms. Panel _content_ (which config keys, help text) is feature work deferred to the owning release; this spike proves only the mechanism. 12. **Spike 12 — Scroll position indicator.** The buffer already scrolls (`View` - mode `j`/`k`/`space`); this spike proves how to *show* position without + mode `j`/`k`/`space`); this spike proves how to _show_ position without wrecking latency. Measure two affordances on the bench: (a) a right-edge scrollbar repainted each scroll step, and (b) a compact side-panel readout (`34/128` or `27%`). Risk: the scrollbar is a tall pixel column repainted on @@ -115,7 +115,7 @@ risk early. 13. **Spike 13 — Line-number gutter.** Draw a fixed-width digit column left of the text area. **Absolute numbering only** — relative numbering was dropped - (2026-07-11). It renumbered the *entire visible gutter on every `j`/`k`*, so + (2026-07-11). It renumbered the _entire visible gutter on every `j`/`k`_, so a single cursor move became a partial refresh of a tall digit column, straight into the Spike 5 windowed-Y path and the "20 partials → forced full refresh" ghosting counter ([`firmware/src/epd.rs`](../firmware/src/epd.rs), @@ -132,10 +132,10 @@ risk early. rows at/below the change, no extra full refresh; closes the last v0.2 gate. 14. **Spike 14 — Multi-file navigation (open / switch / new / delete).** The - panel *mechanism* is already Spike 11 (which names this v0.5 file palette), + panel _mechanism_ is already Spike 11 (which names this v0.5 file palette), and filename entry reuses the v0.4 `:` command line — so this spike proves neither, and depends on both. What is genuinely new is **persistence and - buffer lifecycle**, not rendering: enumerate a FAT directory across *both* + buffer lifecycle**, not rendering: enumerate a FAT directory across _both_ `/sd/repo` and `/sd/local`, keep ≤ 3 ropes resident, and on switch swap the active rope + cursor + soft-wrap after an atomic save-of-current (persistence module). Two sharp edges. **New file** (`:enew`) must prompt @@ -143,7 +143,7 @@ risk early. disabled). **Delete** extends v0.5 scope (the roadmap lists `:enew` but not delete): removing the currently-open buffer must close it and fall back to another resident buffer or an empty one, and — the load-bearing check — the - FAT unlink must reach the next Publish's *staged* set, which the git + FAT unlink must reach the next Publish's _staged_ set, which the git module's `git add .`-equivalent staging may not catch for removals (needs `git rm` / `add -A` semantics — [`git` module](v0.1-mvp-technical.md#git--commit--push)). Prove: diff --git a/docs/tradeoff-curves/README.md b/docs/tradeoff-curves/README.md index 1867977..2df96a8 100644 --- a/docs/tradeoff-curves/README.md +++ b/docs/tradeoff-curves/README.md @@ -7,8 +7,8 @@ > Docs index: [`../README.md`](../README.md). Project overview: > [`../../README.md`](../../README.md). -| Curve | What it decides | -| --- | --- | -| [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. | -| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. | +| Curve | What it decides | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. | +| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. | | [`sync-commit-staging.md`](sync-commit-staging.md) | Commit-staging strategy vs working-tree size — RESOLVED: every index-based path is O(N_tree) and fails on the real repo (611 s / OOM); the O(depth) TreeBuilder splice is benched at ~2–2.8 s and ships. Holds the full measurement trail plus the firmware plumbing plan (merged from the retired handoff note). | diff --git a/docs/tradeoff-curves/epd-refresh-latency.md b/docs/tradeoff-curves/epd-refresh-latency.md index 9f95338..8f1f891 100644 --- a/docs/tradeoff-curves/epd-refresh-latency.md +++ b/docs/tradeoff-curves/epd-refresh-latency.md @@ -2,7 +2,7 @@ > **Model:** on this GDEY0579T93 (SSD1683 dual-controller) panel, refresh time is > set by **how many gate lines (rows, the Y axis) are driven** and **which -> waveform LUT runs** — *not* by the pixel column width. Three refresh modes fall +> waveform LUT runs** — _not_ by the pixel column width. Three refresh modes fall > out of that: full refresh (~1870 ms), full-area partial (~630 ms), and > windowed-Y partial (~100–130 ms for one text line). This is the cost model > behind per-keystroke typing, the boot splash→editor swap @@ -28,15 +28,15 @@ Two things do **not** help, and one thing does: - **Column width (X) is free to keep full.** The panel is a master (`0x00`) + slave (`0x80`) pair with the framebuffer split at the seam; every refresh - drives *both* controllers full width so the seam/mirror math stays intact + drives _both_ controllers full width so the seam/mirror math stays intact (`update_part` / `write_frame_bank` in - [`epd.rs`](../../firmware/src/epd.rs)). Narrowing the *column* saves nothing — + [`epd.rs`](../../firmware/src/epd.rs)). Narrowing the _column_ saves nothing — "the waveform time dominates, not the data clock-out." - **Row count (Y) is the knob.** E-paper drive time scales with the number of gate lines transitioned, so restricting the refresh to a horizontal band of rows is the real win — a one-line band is far cheaper than the whole panel. -- **The LUT sets the tier.** A *partial* update (`0x22`←`0xFF`) runs a short - waveform that only nudges pixels that changed. A *full* update (`0x22`←`0xD7`, +- **The LUT sets the tier.** A _partial_ update (`0x22`←`0xFF`) runs a short + waveform that only nudges pixels that changed. A _full_ update (`0x22`←`0xD7`, the fast-full LUT) runs ~3× as many frames per pixel to fully clear-and-set — slower, but it erases ghosting and re-establishes a known image. @@ -76,11 +76,11 @@ whole panel. 0 25 50 75 100 125 150 175 200 225 250 272 rows ``` -| Mode | Rows driven | Latency | LUT | Used for | -| --- | ---: | ---: | --- | --- | -| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) | -| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap | -| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing | +| Mode | Rows driven | Latency | LUT | Used for | +| ------------------ | -----------: | -------------------------------------: | ---------- | ---------------------------------------------------------------------------------- | +| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) | +| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap | +| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing | ¹ The single-line windowed figure is projected from the floor+slope model and the Spike 5 full-area measurement; the exact bench number is still to be confirmed @@ -96,10 +96,10 @@ partial" — the ~1870 ms tier is a periodic tax paid for longevity, not a mode can retire. **The first cold-boot image must be a full refresh.** After power-on the `0x26` -"previous" bank holds garbage, and a partial refresh *diffs against it* — so the +"previous" bank holds garbage, and a partial refresh _diffs against it_ — so the very first clean paint has to be the full tier. This is why boot pays exactly one unavoidable ~1.9 s full refresh, and why the splash (which rides it) is nearly -free while the *editor's* first frame can be a cheap partial on top. Full +free while the _editor's_ first frame can be a cheap partial on top. Full derivation: [`../notes/boot-time-budget.md`](../notes/boot-time-budget.md). ## What it decides diff --git a/docs/tradeoff-curves/sync-commit-staging.md b/docs/tradeoff-curves/sync-commit-staging.md index 24efa4b..03d173c 100644 --- a/docs/tradeoff-curves/sync-commit-staging.md +++ b/docs/tradeoff-curves/sync-commit-staging.md @@ -30,7 +30,7 @@ > investigation (problem → factor analysis → chosen fix → evaluation): > [`../kaizen/real-repo-sync.md`](../kaizen/real-repo-sync.md). Where the whole sync goes: > [`../notes/sync-latency.md`](../notes/sync-latency.md). Sibling curve on the -> radio cost of *how often* we sync: [`wifi-auto-sync.md`](wifi-auto-sync.md). +> radio cost of _how often_ we sync: [`wifi-auto-sync.md`](wifi-auto-sync.md). ## The model @@ -86,13 +86,13 @@ The gap between the lines at a given N is exactly what switching buys, and it The device syncs into a clone of the actual notes repo, not a `notes.md` toy. Its working tree is **not small**: -| | count | working-tree bytes | -| --- | ---: | ---: | -| Markdown (`.md`) | 875 | ~1.5 MB | -| Images (png/jpg/webp/bmp/gif) | ~260 | **~150 MB** | -| Other (json/ts/pdf/…) | ~44 | ~20 MB | -| **Total (N)** | **1179 files, 158 dirs** | **~170 MB** | -| `.git` history | | ~570 MB | +| | count | working-tree bytes | +| ----------------------------- | -----------------------: | -----------------: | +| Markdown (`.md`) | 875 | ~1.5 MB | +| Images (png/jpg/webp/bmp/gif) | ~260 | **~150 MB** | +| Other (json/ts/pdf/…) | ~44 | ~20 MB | +| **Total (N)** | **1179 files, 158 dirs** | **~170 MB** | +| `.git` history | | ~570 MB | So `add_all(["*"])` walks **1179 files across 158 directories every sync** — and ~260 of them are images that a text edit never changes. That does two things the @@ -113,14 +113,14 @@ toy-repo baseline hides: Split from two back-to-back `:sync`es on the small test repo (commits `95ac56ef` cold, `ab260bde` warm), via the `commit split —` log lines: -| Sub-phase | Kind | Cold (ms) | Warm (ms) | -| --- | --- | ---: | ---: | -| `walk(add_all+update_all)` | scan (O(N)) + likely 1 blob write | 1402 | 1456 | -| `index.write` | FAT write | 204 | 204 | -| `write_tree` | **1 tree object → FAT** | 710 | 715 | -| `parent-load` | FAT read | 102 | 105 | -| `commit-obj` | **1 commit object + ref → FAT** | 914 | 924 | -| **commit total** | | **3332** | **3404** | +| Sub-phase | Kind | Cold (ms) | Warm (ms) | +| -------------------------- | --------------------------------- | --------: | --------: | +| `walk(add_all+update_all)` | scan (O(N)) + likely 1 blob write | 1402 | 1456 | +| `index.write` | FAT write | 204 | 204 | +| `write_tree` | **1 tree object → FAT** | 710 | 715 | +| `parent-load` | FAT read | 102 | 105 | +| `commit-obj` | **1 commit object + ref → FAT** | 914 | 924 | +| **commit total** | | **3332** | **3404** | ### It is not the card — it's libgit2 (`sd_bench`, 2026-07-12) @@ -128,20 +128,20 @@ My first read of the table was "a loose-object write to this SD card costs ~700–900 ms." **That was wrong.** `sd_bench` (`firmware/src/bin/sd_bench.rs`) times the raw FAT primitives on the same card at the same 10 MHz: -| Raw FAT op (200-byte payload) | p50 | -| --- | ---: | -| create + write + close | 21.7 ms | -| rename | 12.8 ms | -| stat (hit / miss) | ~5 ms | -| remove | 14.9 ms | +| Raw FAT op (200-byte payload) | p50 | +| ----------------------------------------------------------- | --------: | +| create + write + close | 21.7 ms | +| rename | 12.8 ms | +| stat (hit / miss) | ~5 ms | +| remove | 14.9 ms | | **loose-object composite** (stat + create + write + rename) | **86 ms** | -The card does a *complete* loose-object write in **~86 ms**. Yet `write_tree` +The card does a _complete_ loose-object write in **~86 ms**. Yet `write_tree` (one tree object) took **710 ms** and `commit-obj` **914 ms** — an **~8× gap that is pure libgit2 overhead, not FAT I/O.** So the earlier "object-write floor / SD write amplification / better card / SPI-clock" framing is refuted: **the SD card is not the bottleneck.** fsync is still confirmed off; the extra ~600 ms/op is CPU or -repeated `.git` I/O *inside* libgit2 (candidates: ODB refresh scanning +repeated `.git` I/O _inside_ libgit2 (candidates: ODB refresh scanning `objects/`, the treebuilder's per-entry `git_odb_exists`, ref-lock + reflog writes, config/attributes re-reads). `git_bench` (`firmware/src/bin/git_bench.rs`) localizes it — see below. @@ -152,15 +152,15 @@ it — see below. same 96 KB thread the real service uses — the main-task stack overflows on `index.write`, which is itself the reason the service has a dedicated thread): -| git2 op | p50 | note | -| --- | ---: | --- | -| `Repository::open` | 100 ms | one-time | -| `odb.write(blob)` (unique) | **45 ms** | writes a fresh object; touches no existing object | -| `repo.index()` open | ~0 ms | cached | -| `index.write()` | 376 ms | index + `index.lock` rename + tree-cache | -| `write_tree` [unchanged] | ~0 ms | tree exists → freshen-skips the write | -| **`write_tree` [changed]** | **1136 ms** | writes ONE 45 ms object | -| **`commit(None)` orphan obj** | **563 ms** | writes ONE 45 ms object, no ref/reflog | +| git2 op | p50 | note | +| ----------------------------- | ----------: | ------------------------------------------------- | +| `Repository::open` | 100 ms | one-time | +| `odb.write(blob)` (unique) | **45 ms** | writes a fresh object; touches no existing object | +| `repo.index()` open | ~0 ms | cached | +| `index.write()` | 376 ms | index + `index.lock` rename + tree-cache | +| `write_tree` [unchanged] | ~0 ms | tree exists → freshen-skips the write | +| **`write_tree` [changed]** | **1136 ms** | writes ONE 45 ms object | +| **`commit(None)` orphan obj** | **563 ms** | writes ONE 45 ms object, no ref/reflog | Writing a fresh object is 45 ms; the ops that wrap one are 8–25×. The cause, from the vendored source: `git_odb_write` calls `git_odb__freshen` (odb.c:1011), which @@ -184,16 +184,16 @@ open risk in sync. dirs, 570 MB pack). It settles the design: **any index-based commit is O(N_tree) and does not fit this device.** Two independent walls: -| op | result | reading | -| --- | ---: | --- | -| `Repository::open` | 88 ms | fine | -| odb open (implicit) | ~6 s cold | maps the 1.7 MB pack `.idx` once (16 miss / 1790 KB) | -| `odb.write(blob)` | **142 ms** p50 | the mmap cache win **holds** (was 862 ms uncached) ✅ | -| `repo.index()` load (1179 entries) | 514 ms max | the on-disk index we were trying to avoid | -| `index.write()` | **min 360 ms / p50 12.8 s / max 611 s** | ⚠️ hangs — see root cause | -| **seed `read_tree(HEAD)` (cold, 1×)** | **~77 s** | ⚠️ reads all ~158 tree objects, 22.7 MB of pack windows | -| `Index::new + read_tree` (warm) | 447 ms p50 | windows still mapped → pure CPU | -| **index-free `stage→tree`** | **💥 crash** | `zlib (5)`: `deflateInit` failed, **508 KB heap left** | +| op | result | reading | +| ------------------------------------- | --------------------------------------: | ------------------------------------------------------- | +| `Repository::open` | 88 ms | fine | +| odb open (implicit) | ~6 s cold | maps the 1.7 MB pack `.idx` once (16 miss / 1790 KB) | +| `odb.write(blob)` | **142 ms** p50 | the mmap cache win **holds** (was 862 ms uncached) ✅ | +| `repo.index()` load (1179 entries) | 514 ms max | the on-disk index we were trying to avoid | +| `index.write()` | **min 360 ms / p50 12.8 s / max 611 s** | ⚠️ hangs — see root cause | +| **seed `read_tree(HEAD)` (cold, 1×)** | **~77 s** | ⚠️ reads all ~158 tree objects, 22.7 MB of pack windows | +| `Index::new + read_tree` (warm) | 447 ms p50 | windows still mapped → pure CPU | +| **index-free `stage→tree`** | **💥 crash** | `zlib (5)`: `deflateInit` failed, **508 KB heap left** | **Wall 1 — `index.write()` hashes the whole working tree (up to 611 s).** `git_index_write` unconditionally calls `truncate_racily_clean` (index.c:822), @@ -218,7 +218,7 @@ the index-free path only trades a 611 s hash for a 77 s tree-read. Worse, that `read_tree` drove the `esp_map.c` cache to **7.4 MB resident** — past its own 4 MB soft cap — which left 508 KB of heap and made `repo.blob()`'s zlib `deflateInit` fail. **The one write we cannot skip crashed.** Root cause of the OOM: our cache -holds pack windows *after* libgit2 `p_munmap`s them (refcount 0, freed only lazily +holds pack windows _after_ libgit2 `p_munmap`s them (refcount 0, freed only lazily on the next `p_mmap`), which **defeats `GIT_OPT_SET_MWINDOW_MAPPED_LIMIT`** — libgit2 thinks it released the memory; we didn't. @@ -242,14 +242,14 @@ depth-3 path `.claude/commands/bsky.md`, run FIRST so its first iteration is cold; the index ops moved last so their OOM can't cost the new data — it did crash again, after everything was logged): -| op | result | reading | -| --- | ---: | --- | -| `splice stage→tree` (1 blob + 3 trees) | **6.5 s p50, warm ≈ cold** | O(depth) confirmed — cost is 4 loose writes × ~1.6 s | -| `commit(None)` orphan obj | 1.7 s p50 | one more loose write | -| `odb.write(blob)` | **1.5 s p50** | ⚠️ was 142 ms in the previous run | -| `repo.index()` load | 524 ms max | matches previous run | -| seed `read_tree(HEAD)` cold (now timed) | 81.6 s | reproduces the 77 s | -| `index-free stage→tree` | 💥 crash, 508 KB heap | reproduces the zlib OOM exactly | +| op | result | reading | +| --------------------------------------- | -------------------------: | ---------------------------------------------------- | +| `splice stage→tree` (1 blob + 3 trees) | **6.5 s p50, warm ≈ cold** | O(depth) confirmed — cost is 4 loose writes × ~1.6 s | +| `commit(None)` orphan obj | 1.7 s p50 | one more loose write | +| `odb.write(blob)` | **1.5 s p50** | ⚠️ was 142 ms in the previous run | +| `repo.index()` load | 524 ms max | matches previous run | +| seed `read_tree(HEAD)` cold (now timed) | 81.6 s | reproduces the 77 s | +| `index-free stage→tree` | 💥 crash, 508 KB heap | reproduces the zlib OOM exactly | Three readings: @@ -265,7 +265,7 @@ Three readings: 862→142 ms `odb.write` win did **not reproduce** (same `esp_map.c`, same card). Either the earlier run's conditions differed (orphan-object population? FAT allocation state?) or the win was misattributed. Whatever the - 1.5 s is, it is *not* SD data volume: each write moves ~40 KB read + ~1 KB + 1.5 s is, it is _not_ SD data volume: each write moves ~40 KB read + ~1 KB written. ### ROOT CAUSE FOUND (2026-07-12, `sd_bench` seek op): FatFS lseek walks the cluster chain @@ -316,11 +316,11 @@ back to slow seeks, so the headroom matters). **A/B measured (same evening): a 2.3× partial win, not a full one.** -| op | fast-seek off | fast-seek on | -| --- | ---: | ---: | -| `splice stage→tree` | 6.5 s | **2.81 s** | -| `odb.write(blob)` | 1.5 s | **416 ms** | -| `commit(None)` | 1.7 s | **1.72 s — unchanged** | +| op | fast-seek off | fast-seek on | +| ------------------- | ------------: | ---------------------: | +| `splice stage→tree` | 6.5 s | **2.81 s** | +| `odb.write(blob)` | 1.5 s | **416 ms** | +| `commit(None)` | 1.7 s | **1.72 s — unchanged** | `odb.write` dropped by almost exactly the ~6 chain walks the model predicted — the seek theory holds — but two residuals remain: **~400 ms per loose write** @@ -340,17 +340,17 @@ pack is not too fragmented). A far seek is now ~15 ms, i.e. effectively fixed. **The strict-creation theory is refuted; the probes found the real unit cost:** -| op | p50 | reading | -| --- | ---: | --- | -| `odb.read_header(packed)` | **470 ms** | ONE pack header resolve costs ~½ s | -| `odb.exists(missing)` | **968 ms** (±0.1 ms) | miss path (scan → refresh → rescan) ≈ 2× | -| `commit(None)` strict OFF | 1.80 s | vs 1.93 s strict on — validation is NOT the premium | -| `splice` strict OFF | 5.7 s | noise-worse; also not validation | +| op | p50 | reading | +| ------------------------- | -------------------: | --------------------------------------------------- | +| `odb.read_header(packed)` | **470 ms** | ONE pack header resolve costs ~½ s | +| `odb.exists(missing)` | **968 ms** (±0.1 ms) | miss path (scan → refresh → rescan) ≈ 2× | +| `commit(None)` strict OFF | 1.80 s | vs 1.93 s strict on — validation is NOT the premium | +| `splice` strict OFF | 5.7 s | noise-worse; also not validation | The ±0.1 ms constancy of `exists(missing)` = a fixed, deterministic SD-op sequence. The map counters identify it: **~7–8 small (~4 KB) `p_mmap` reads per op** — pack trailer probes, idx fanout reads and delta-base windows, repeated -at the *same offsets* on every freshen/refresh. Post-fast-seek those cost +at the _same offsets_ on every freshen/refresh. Post-fast-seek those cost ~20 ms each (~150 ms/op); the rest of `read_header`'s 470 ms is CPU-side delta-chain inflation on the 160 MHz core plus repeated re-reads. Two other observations from run 3b: the loose-object orphan population from bench runs @@ -372,16 +372,16 @@ splice at or under the sub-second bar, and no end-of-run zlib OOM. Run 4 (2026-07-12 evening, same card state as run 3b plus its orphans): -| op | run 3b (fast-seek) | run 4 (+ esp_map v2) | -| --- | ---: | ---: | -| `splice stage→tree` (cold, first op) | 2.81 s | **2.83 s — unchanged** | -| `splice` again (warm, strict-off phase) | 3.21 s | 1.95 s | -| `commit(None)` | 1.72 s | 713 ms | -| `odb.write(blob)` | 416 ms | 366 ms | -| `odb.read_header(packed)` | 470 ms | 412 ms | -| `odb.exists(missing)` | 968 ms | 852 ms | -| mmap-cache hits | 0 | **0** (313 misses) | -| cache resident / heap free | grew to 7.4 MB → zlib OOM | **1833 KB flat / 6.4 MB free all run** | +| op | run 3b (fast-seek) | run 4 (+ esp_map v2) | +| --------------------------------------- | ------------------------: | -------------------------------------: | +| `splice stage→tree` (cold, first op) | 2.81 s | **2.83 s — unchanged** | +| `splice` again (warm, strict-off phase) | 3.21 s | 1.95 s | +| `commit(None)` | 1.72 s | 713 ms | +| `odb.write(blob)` | 416 ms | 366 ms | +| `odb.read_header(packed)` | 470 ms | 412 ms | +| `odb.exists(missing)` | 968 ms | 852 ms | +| mmap-cache hits | 0 | **0** (313 misses) | +| cache resident / heap free | grew to 7.4 MB → zlib OOM | **1833 KB flat / 6.4 MB free all run** | Three findings: @@ -393,7 +393,7 @@ Three findings: `read_tree`; the tail would only re-confirm eviction under burst. 2. **The repeated-small-window theory is REFUTED — theory #3 down** (after strict-creation and free-cluster-scan). v2 demonstrably admits and retains - the small maps now — the 1833 KB resident *is* them, held below low-water so + the small maps now — the 1833 KB resident _is_ them, held below low-water so nothing is evicted before reuse — and still scored 0 hits in 313 misses. So the ~8 small reads per loose write hit **unique (offset, len) every time**: `mwindow` was already absorbing any true repetition above `p_mmap`, @@ -403,7 +403,7 @@ Three findings: (post-fast-seek) — I/O count, not I/O size or seek cost. 3. **Within-run drift cuts both ways, so cross-run tables are mushy.** In this single run `commit(None)` degraded 713 ms → 1.79 s between the early and - late (strict-off) phases, while splice *improved* 2.83 → 1.95 s. Two + late (strict-off) phases, while splice _improved_ 2.83 → 1.95 s. Two competing effects: first-touch warm-up fading (CLMT build, first pack reads — helps later ops) and orphan loose objects accumulating in `.git/objects/xx/` slowing every freshen existence check (FAT directory @@ -428,7 +428,7 @@ was aspirational; measured reality is ~2–2.8 s to commit on the real 263 MB-pack repo versus 611 s (or a hard OOM) for every alternative benched. That puts a full real-repo `:sync` at roughly **9–10 s cold**, which ships. The remaining ~2 s has survived four localization rounds; the next suspect — -FAT *directory-op* cost in the freshen/refresh path (open/stat/rename by path +FAT _directory-op_ cost in the freshen/refresh path (open/stat/rename by path walk FAT directories linearly; consistent with the orphan-creep signal) — is one instrumentation pass for later (log each `p_mmap` miss + bench dir ops in `sd_bench`), not a prerequisite for the plumbing. @@ -440,7 +440,7 @@ the real card at 10 MHz: 1. `stat` on a **missing** name (the freshen probe's shape — no early exit, the whole parent is scanned) is linear in sibling count: 4.7 ms at 8 siblings → 12.8 ms at 64 → 28.9 ms at 256 ≈ **~0.1 ms per directory entry - per scan**. (`stat` on a *present* name plateaued at ~8 ms, but that's the + per scan**. (`stat` on a _present_ name plateaued at ~8 ms, but that's the bench's shape — 20 iterations only ever touch the first 20 entries, which sit early in the scan.) 2. The **loose-object composite** (freshen stat-miss + temp create + write + @@ -456,11 +456,11 @@ The real repo's `.git/objects/` holds up to 256 two-hex fan-out dirs plus `pack/`/`info/`, so every path resolution under it pays the ~256-entry scan — that's the whole residual. It composes with the earlier finding that the splice does **0 pack reads** during these writes (run-1 `p_mmap` counters): -nothing here is data I/O. Consequences: (a) the cost is *bounded* — ~0.4 s × +nothing here is data I/O. Consequences: (a) the cost is _bounded_ — ~0.4 s × loose objects per commit (typically ≤ 5) ≈ ≤ 2 s, already inside the shipped -splice numbers, so nothing burns; (b) the only real levers are *fewer -path-resolutions per object* (libgit2 surgery) or *not writing loose objects -at all* (commit straight into a small pack) — both out of scope until a +splice numbers, so nothing burns; (b) the only real levers are _fewer +path-resolutions per object_ (libgit2 surgery) or _not writing loose objects +at all_ (commit straight into a small pack) — both out of scope until a future perf pass; (c) orphan-creep is now mechanically explained: each leftover loose object adds entries to a fan-out dir and slows every later scan of it. @@ -501,10 +501,10 @@ work, ranked: the cache itself holding buffers past `p_munmap`. esp_map.c is now the plain malloc-read/free-at-munmap emulation: honest with `MWINDOW_MAPPED_LIMIT` by construction, ~120 lines lighter, and run 5 - confirmed removal is I/O-neutral (even 15 reads *better* than v2, whose + confirmed removal is I/O-neutral (even 15 reads _better_ than v2, whose low-water eviction fought mwindow). Stats counters kept to spot any future workload that does repeat ranges. -3. **Retired: `add_all`/explicit-path *index* staging.** Explicit-path `add_path` +3. **Retired: `add_all`/explicit-path _index_ staging.** Explicit-path `add_path` still goes through the index and `index.write` → `truncate_racily_clean`, so it hits Wall 1 just the same. The TreeBuilder walk supersedes it entirely; the "explicit-path staging" idea survives only as "the editor's dirty set feeds the @@ -540,7 +540,8 @@ untouched — which means **the device doesn't even need the images in its working tree.** Shipped as `git_sync::splice` (git2 0.20: `Repository::treebuilder(Option<&Tree>)` -+ `TreeBuilder::{insert,remove,write}`). Signature: + +- `TreeBuilder::{insert,remove,write}`). Signature: ```rust fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option) -> Result @@ -579,7 +580,7 @@ log survive. The benched reference is `git_bench`'s `splice stage→tree` op. answers in ~150 ms instead of a Wi-Fi/TLS round. - `reconcile_onto_origin` now `ResetType::Soft` (ref move only) — there is no index to reset, and a Mixed reset's index write is exactly the racy-clean - wall the splice avoids. Side win: a remote-only added file is now *carried* + wall the splice avoids. Side win: a remote-only added file is now _carried_ by the replay (origin's tree is the splice base) where the old `add --all` replay dropped it. - The macOS-cruft filter (`skip_macos_cruft`) is gone with the walk — the @@ -605,8 +606,8 @@ log survive. The benched reference is `git_bench`'s `splice stage→tree` op. (journal keeps carrying the union); the outcome settles it — `publish_succeeded()` forgets the snapshot and shrinks the journal, `publish_failed()` returns it to pending for the next `:sync`. A save landing - *while* a publish runs re-enters pending and rides the next one. Recording - happens *before* the file write, so a crash between the two only + _while_ a publish runs re-enters pending and rides the next one. Recording + happens _before_ the file write, so a crash between the two only over-approximates (a no-op splice), never under-records. - `Effect::Publish` in `main.rs` sends `PublishRequest { paths: take_dirty() }`; the outcome handler in the idle branch calls the matching settle method. @@ -666,9 +667,9 @@ Git-LFS-style pointers — is a separate decision tracked in That lever shrinks N and the clone; this one stops the walk from paying for N. They compose. -## What this does *not* touch +## What this does _not_ touch The network half of `:sync` (TLS handshake + push round-trips, ~6.5 s of the warm path) is a separate floor covered in [`sync-latency.md`](../notes/sync-latency.md); -this curve is only about the local commit. Radio *frequency* (how often we pay any +this curve is only about the local commit. Radio _frequency_ (how often we pay any sync at all) is [`wifi-auto-sync.md`](wifi-auto-sync.md). diff --git a/docs/tradeoff-curves/wifi-auto-sync.md b/docs/tradeoff-curves/wifi-auto-sync.md index c218a41..c1195b3 100644 --- a/docs/tradeoff-curves/wifi-auto-sync.md +++ b/docs/tradeoff-curves/wifi-auto-sync.md @@ -1,7 +1,7 @@ # Wi-Fi energy vs auto-sync interval -> **Decision:** `auto_sync` defaults to **10 min**, and is an *opportunistic, -> rate-limited* push — not a wall-clock timer that wakes the device. See +> **Decision:** `auto_sync` defaults to **10 min**, and is an _opportunistic, +> rate-limited_ push — not a wall-clock timer that wakes the device. See > [Policy](#policy). Backs the `.typoena.toml` `auto_sync` key in > [`../macroplan.md`](../macroplan.md) (v0.5). The runtime behaviour was > pencilled for v0.7, but v0.7 closed 2026-07-14 as search + manual `:gl`/`:gp` @@ -15,7 +15,7 @@ > [`../v0.7-search-and-git.md`](../v0.7-search-and-git.md)): the per-sync > constant roughly **doubles** and its mix shifts from radio to SD/CPU, but the > shape, the knee, and the decision all survive — and two v0.7 results make the -> teardown-between-syncs argument *stronger*. +> teardown-between-syncs argument _stronger_. > > Tradeoff-curves index: [`README.md`](README.md). Docs index: > [`../README.md`](../README.md). @@ -34,7 +34,7 @@ push leg (connect + pack + upload) 5.9 s TLS-resumed connect 2.4 s (4 warm `:gp` end-to-end 19.1 s root-level file ≈ 12–13 s — the splice is depth-bound ``` -Both halves are paid once per *dirty* sync, so energy per unit time still +Both halves are paid once per _dirty_ sync, so energy per unit time still scales as **(fixed cost per sync) × (syncs per hour)**: ``` @@ -46,15 +46,15 @@ wrote barely move it. What the measurement changed is **K and its composition**: the awake window is ~12–19 s (not the ~8 s placeholder), and more than half of it is SD/CPU splice work that no radio policy can touch — but the splice re-runs on every sync too, so it rides the same 1/T curve. The -splice scales with file *depth* and dirty-set size, not with words (the +splice scales with file _depth_ and dirty-set size, not with words (the FAT-dir-scan story in [`sync-commit-staging.md`](sync-commit-staging.md), run 6). -Placeholder *currents*, still pending the v0.8 bench measurement ("measure +Placeholder _currents_, still pending the v0.8 bench measurement ("measure idle / typing / push current draw"): ~10 s of CPU+SD at ~100 mA plus ~8 s of radio-on at ~150 mA ⇒ **≈ 0.6 mAh per sync**, `K ≈ 36 mAh·min/hr` — about 2× the original estimate. The vertical scale below moves with the real current -measurement; the *shape* and the knee do not. +measurement; the _shape_ and the knee do not. Two refinements the field runs added: @@ -64,7 +64,7 @@ Two refinements the field runs added: push — idle hours with no edits cost zero regardless of the interval. - **The worst case is bounded at ~2×.** If the mirror moved underneath (a Mac push), an auto-sync hits the rejected-push → reconcile → replay → push cycle: - 24.0 s and three connections measured (run 3, *with* session resumption). + 24.0 s and three connections measured (run 3, _with_ session resumption). Rare, and it doesn't move the knee. **One assumption is baked into the burst: the radio is fully off between @@ -79,7 +79,7 @@ further: session ticket lives in RAM — it survives radio de-init between syncs. - **Held connections die anyway.** In run 8 the idle keep-alive was closed during a ~31 s quiet gap and libgit2 does not reconnect (`SSL Generic - error`, push lost). Keep-alive is not just expensive; it's fragile. +error`, push lost). Keep-alive is not just expensive; it's fragile. So each sync legitimately pays a fresh `wake → associate → resumed-handshake` burst, and "off" everywhere here means radio **de-init**, not @@ -89,13 +89,13 @@ pull, so "Typoena only ever pushes" is no longer literally true — but pull is user-initiated and outbound like everything else; nothing unsolicited ever arrives, so there is still no reason to stay reachable.) -> **Status (v0.7) — the shipped firmware still does *not* cycle the radio.** +> **Status (v0.7) — the shipped firmware still does _not_ cycle the radio.** > Wi-Fi comes up lazily on the first git op and then stays associated for the > rest of the session: `ensure_online` in > [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs) owns the > `wifi` handle and the module never stops, disconnects, or drops it (the > `remote.disconnect()` calls in there are git smart-protocol connections, not -> the radio). So today's device runs the *stay-associated* strategy this +> the radio). So today's device runs the _stay-associated_ strategy this > section argues against, at ~15–20 mAh/hr after the first git op. Per-sync > teardown remains a v0.8 refactor of the modem ownership — and a prerequisite > before any sleep mode ships. @@ -117,15 +117,15 @@ The knee sits at **5–10 min**: left of it, every extra sync per hour costs a full splice + radio burst for zero payload benefit; right of it, the tail is nearly flat and longer intervals save almost nothing. -| interval | syncs/hr | Wi-Fi mAh/hr | vs 5-min | per 8 h day | -| ---: | ---: | ---: | ---: | ---: | -| 1 min | 60 | 36.0 | 5.0× | 288 mAh | -| 2 min | 30 | 18.0 | 2.5× | 144 mAh | -| 5 min | 12 | 7.2 | 1.0× | 58 mAh | -| **10 min** | 6 | **3.6** | **0.5×** | 29 mAh | -| 15 min | 4 | 2.4 | 0.33× | 19 mAh | -| 30 min | 2 | 1.2 | 0.17× | 9.6 mAh | -| 60 min | 1 | 0.6 | 0.08× | 4.8 mAh | +| interval | syncs/hr | Wi-Fi mAh/hr | vs 5-min | per 8 h day | +| ---------: | -------: | -----------: | -------: | ----------: | +| 1 min | 60 | 36.0 | 5.0× | 288 mAh | +| 2 min | 30 | 18.0 | 2.5× | 144 mAh | +| 5 min | 12 | 7.2 | 1.0× | 58 mAh | +| **10 min** | 6 | **3.6** | **0.5×** | 29 mAh | +| 15 min | 4 | 2.4 | 0.33× | 19 mAh | +| 30 min | 2 | 1.2 | 0.17× | 9.6 mAh | +| 60 min | 1 | 0.6 | 0.08× | 4.8 mAh | (Every row assumes the device was dirty at each tick — a writing session in progress. Clean ticks cost nothing, per the journal gate above.) @@ -134,22 +134,22 @@ progress. Clean ticks cost nothing, per the journal gate above.) **`save_on_idle` already prevents data loss — auto-sync is only remote-mirror freshness.** The durable local copy is the SD write on the idle pause. A longer -sync interval never risks *losing work*; it only means the GitHub mirror is a +sync interval never risks _losing work_; it only means the GitHub mirror is a few minutes staler. That's a weak cost, and it pushes the optimum toward -*longer* intervals. The doubled K pushes the same direction: each avoided sync +_longer_ intervals. The doubled K pushes the same direction: each avoided sync now saves twice what the original model claimed. **The real battery risk is the sleep interaction, not the awake case.** While you're typing, the CPU/e-ink baseline dwarfs the sync cost — 5 vs 15 min is noise. The damage happens when the device is idle or asleep and a wall-clock -timer wakes it *just to push*: each wake pays the radio burst plus the wake/boot +timer wakes it _just to push_: each wake pays the radio burst plus the wake/boot cost and blocks the low-power state. That turns "closed on the desk overnight" from weeks of standby into dead-by-morning. ## Policy Ship `auto_sync` as an opportunistic, rate-limited push, with the config value -read as a *max-staleness cap* rather than a timer period: +read as a _max-staleness cap_ rather than a timer period: - **Push when already awake + dirty**, coalesced into the existing idle-pause, rate-limited to at most once per `auto_sync` — so a fast typist pausing every diff --git a/docs/typoena-snippets.md b/docs/typoena-snippets.md index 27d805d..fa0ebc0 100644 --- a/docs/typoena-snippets.md +++ b/docs/typoena-snippets.md @@ -8,8 +8,8 @@ > [`v0.6-markdown.md`](v0.6-markdown.md). > > **Three files, three concerns, don't confuse them.** `.typoena.snippets.json` -> is *content* (your templates). [`.typoena.toml`](typoena-toml.md) is *behaviour* -> (auto-save, gutter). `/sd/typoena.conf` is *secrets* (Wi-Fi, PAT), gitignored +> is _content_ (your templates). [`.typoena.toml`](typoena-toml.md) is _behaviour_ +> (auto-save, gutter). `/sd/typoena.conf` is _secrets_ (Wi-Fi, PAT), gitignored > and never committed. The first two live in the repo and sync; the third is > per-device. diff --git a/docs/typoena-toml.md b/docs/typoena-toml.md index 6b3f080..60c8cff 100644 --- a/docs/typoena-toml.md +++ b/docs/typoena-toml.md @@ -7,9 +7,9 @@ > [`macroplan.md`](macroplan.md)). > > **Not to be confused with `/sd/typoena.conf`** — that holds the device -> *secrets* (Wi-Fi, PAT, remote URL, commit author), is gitignored, and is never -> committed. `.typoena.toml` is *behaviour*, shared across devices; `typoena.conf` -> is *secrets*, per-device. See [v0.1 product](v0.1-mvp-product.md). +> _secrets_ (Wi-Fi, PAT, remote URL, commit author), is gitignored, and is never +> committed. `.typoena.toml` is _behaviour_, shared across devices; `typoena.conf` +> is _secrets_, per-device. See [v0.1 product](v0.1-mvp-product.md). ## Location @@ -32,14 +32,14 @@ works with no config present. ## Keys -| Key | Type | Default | Options | Effect | -| --- | --- | --- | --- | --- | -| `save_on_idle` | bool | `true` | `true` / `false` | Auto-save the current buffer on the idle typing-pause, so `:w` is optional. | -| `format_on_save` | bool | `true` | `true` / `false` | Run `:fmt` on the buffer before an explicit `:w`/`:sync`. | -| `line_numbers` | bool | `true` | `true` / `false` | Show the absolute line-number gutter. Off reclaims its columns for text. | -| `open_last_on_boot` | bool | `true` | `true` / `false` | Boot into the file that was active at power-off, instead of `notes.md`. | -| `theme` | string | `"light"` | `light` / `dark` | Panel colour polarity. `dark` inverts the whole frame to white-on-black. | -| `auto_sync` | string | `"10m"` | `2m` / `5m` / `10m` / `15m` / `30m` | Max-staleness cap for opportunistic auto-publish. **Value only — no behaviour yet** (rides v0.8, with the sleep work). | +| Key | Type | Default | Options | Effect | +| ------------------- | ------ | --------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `save_on_idle` | bool | `true` | `true` / `false` | Auto-save the current buffer on the idle typing-pause, so `:w` is optional. | +| `format_on_save` | bool | `true` | `true` / `false` | Run `:fmt` on the buffer before an explicit `:w`/`:sync`. | +| `line_numbers` | bool | `true` | `true` / `false` | Show the absolute line-number gutter. Off reclaims its columns for text. | +| `open_last_on_boot` | bool | `true` | `true` / `false` | Boot into the file that was active at power-off, instead of `notes.md`. | +| `theme` | string | `"light"` | `light` / `dark` | Panel colour polarity. `dark` inverts the whole frame to white-on-black. | +| `auto_sync` | string | `"10m"` | `2m` / `5m` / `10m` / `15m` / `30m` | Max-staleness cap for opportunistic auto-publish. **Value only — no behaviour yet** (rides v0.8, with the sleep work). | The **Options** column is what the palette rotates through on **Enter**; a boolean is just the two-option case. Hand-editing a string key can still set any @@ -66,7 +66,7 @@ of writing. It is a **safety net, not an action**: - **Silent.** No snackbar, no forced screen refresh. A visible confirmation on every pause would cost a ~630 ms e-ink flash purely to say "saved" — exactly - the gratuitous flashing the panel avoids elsewhere. `:w` remains the *loud* + the gratuitous flashing the panel avoids elsewhere. `:w` remains the _loud_ save (it posts `saved`). - **Unformatted.** The idle save never runs `:fmt` — see the [format_on_save](#format_on_save) note for why. @@ -76,12 +76,12 @@ of writing. It is a **safety net, not an action**: ### `format_on_save` Runs `:fmt` — table alignment, blank-line collapse, trailing-whitespace strip — -on the buffer *before* it is persisted, so `:sync` is **fmt → save → commit → +on the buffer _before_ it is persisted, so `:sync` is **fmt → save → commit → push** and `:w` saves formatted. **Formatting only happens on an explicit `:w`/`:sync`.** The `save_on_idle` auto-save is deliberately left unformatted: if it reformatted on every idle -pause, tables would reflow and blank lines collapse *mid-session*, with the caret +pause, tables would reflow and blank lines collapse _mid-session_, with the caret jumping under you every time you paused to think. Formatting is a deliberate act; the safety-net save is not. @@ -98,7 +98,7 @@ Boot into the file that was active when the device powered off, instead of the default `notes.md` — power the typewriter back on and you are where you left off, caret on the last character as always. -Only the **choice** lives in this file. The last-active *path* is device state, +Only the **choice** lives in this file. The last-active _path_ is device state, not shared behaviour: the firmware keeps it in a device-local marker (`/sd/.typoena-last`, beside the dirty journal at the card root), rewritten on every buffer switch. It is deliberately **not** stored here — a tracked key @@ -128,13 +128,13 @@ the palette repaints inverted at once. ### `auto_sync` A duration string that will one day cap how stale the published copy is allowed -to get — an *opportunistic, rate-limited* push, not a wall-clock timer. The +to get — an _opportunistic, rate-limited_ push, not a wall-clock timer. The palette rotates it through the presets `2m` / `5m` / `10m` / `15m` / `30m` (hand-editing can still set any string, e.g. `"0"`/empty to disable). **The value is only stored and displayed in v0.5 — nothing reads it yet:** the periodic push lands with the sleep work in v0.8 (originally pencilled on v0.7's git work; v0.7 closed 2026-07-14 with manual `:gl`/`:gp` only), so -cycling the interval today changes what will be honoured *then*, not now. +cycling the interval today changes what will be honoured _then_, not now. Rationale for the `"10m"` default: [`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md). @@ -184,7 +184,7 @@ Rules: - A `#` starts a comment to end of line (whole-line or trailing). - Blank lines and lines without `=` are ignored. - An **unrecognized key** is ignored; an **unparseable value** (e.g. - `save_on_idle = yes`) leaves *that key* at its default rather than reading as + `save_on_idle = yes`) leaves _that key_ at its default rather than reading as `false`. - Any key not present falls back to its default, so partial files are valid. diff --git a/docs/v0.1-mvp-product.md b/docs/v0.1-mvp-product.md index 85ab711..1b6691f 100644 --- a/docs/v0.1-mvp-product.md +++ b/docs/v0.1-mvp-product.md @@ -123,11 +123,11 @@ context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display- - **Side panel** (right, ~150 px / ~20 cols, full height): all metadata lives here, so the writing column keeps the full height. Top — **filename** + dirty dot (●/○). Middle — **word count** and **elapsed time**, refreshed on a short - typing pause, *not* per keystroke. Bottom-left — the **mode** indicator + typing pause, _not_ per keystroke. Bottom-left — the **mode** indicator (always `INSERT` in v0.1) with any pending count/operator echo. Bottom — ambient state: **clock** (if SNTP has set the time), **Wi-Fi** (`—` = off/on-demand, `✓` = connected, `✗` = failed), a **keyboard-disconnect** flag - (rendered `NO KBD` — Latin-9 has no `⌨`/`✗` glyph — shown *only* while the + (rendered `NO KBD` — Latin-9 has no `⌨`/`✗` glyph — shown _only_ while the keyboard is dropped; blank when healthy, per [`CONTEXT.md`](../CONTEXT.md) "No state the user didn't ask for"), and **publish state**. `Ctrl-S` briefly flashes "saved HH:MM"; @@ -135,7 +135,7 @@ context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display- commit-landed moment, when the user's work is durable (see "Durability before delivery" in [`CONTEXT.md`](../CONTEXT.md)) — and ends at `✓ pushed abc1234` (or `publish failed: `, with the local commit preserved either way). -- **Deliberately not shown:** any cursor-position readout (*line* or *column*), +- **Deliberately not shown:** any cursor-position readout (_line_ or _column_), character count, or reading-time estimate — each repaints a panel row on cursor movement or per keystroke, on a row apart from the cursor, doubling the ghosting rate for little writerly value. v0.1 ships no positional readout at all. @@ -164,15 +164,15 @@ a save. ## Error UX -| Failure | What the user sees | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| No SD card on boot | "no SD card — insert one and reboot" | -| 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) | panel Wi-Fi field shows `✗`; dev rebuilds with correct env vars and re-flashes | -| Wi-Fi won't connect | panel Wi-Fi field shows `✗`; editing still works; `Ctrl-G`'s publish-state field shows "no network" | +| Failure | What the user sees | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No SD card on boot | "no SD card — insert one and reboot" | +| 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) | panel Wi-Fi field shows `✗`; dev rebuilds with correct env vars and re-flashes | +| Wi-Fi won't connect | panel Wi-Fi field shows `✗`; editing still works; `Ctrl-G`'s publish-state field shows "no network" | | `Ctrl-G` publish fails (auth, network, merge conflict) | panel publish-state field shows the 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 | panel save field flashes "save failed"; buffer stays dirty | -| Keyboard disconnects | panel shows `⌨ ✗` (blank once reconnected); editing pauses; reconnects automatically | +| File write fails | panel save field flashes "save failed"; buffer stays dirty | +| Keyboard disconnects | panel shows `⌨ ✗` (blank once reconnected); editing pauses; reconnects automatically | No modal dialogs. Errors live in the side panel's ambient-state fields. The editor is never blocked from accepting keystrokes (except during the diff --git a/docs/v0.1-mvp-technical.md b/docs/v0.1-mvp-technical.md index a0941f1..e9b6c8b 100644 --- a/docs/v0.1-mvp-technical.md +++ b/docs/v0.1-mvp-technical.md @@ -42,13 +42,13 @@ blocked by I/O. ### Threads / tasks -| Task | Core | Stack | Responsibility | -| ------------- | ---- | ----- | -------------------------------------------------------- | -| `usb_task` | 0 | 8 KB | TinyUSB host loop, decode HID reports, post `KeyEvent`s | +| Task | Core | Stack | Responsibility | +| ------------- | ---- | ----- | --------------------------------------------------------- | +| `usb_task` | 0 | 8 KB | TinyUSB host loop, decode HID reports, post `KeyEvent`s | | `wifi_task` | 0 | 8 KB | On-demand station: off by default, brought up by `Ctrl-G` | -| `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 | 96 KB | Triggered by `Ctrl-G`; runs libgit2 commit + push | +| `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 | 96 KB | Triggered by `Ctrl-G`; runs libgit2 commit + push | All inter-task communication is via `crossbeam-channel` or `std::sync::mpsc` bounded queues. The editor state is `Arc>`; the lock is @@ -195,7 +195,7 @@ hardware-verified 2026-07-11. radio; on-demand is closer to ~25 mAh/day at 10 Publishes/day. See [qfd.md H13](qfd.md#6-critical-performance-budget). - **Bring-up on `Ctrl-G`.** `wifi_task` transitions `Off → Associating → - Connected`. Association timeout: 10 s. If association fails, the local +Connected`. Association timeout: 10 s. If association fails, the local commit is preserved and the side panel's Wi-Fi field shows `✗`; no background retry. - **Grace window** of ~90 s after a Publish completes. A `Ctrl-G` inside the window reuses the connection (no second association cost). If no @@ -294,7 +294,7 @@ ESP32-S3-N16R8: 512 KB SRAM + 8 MB PSRAM. We budget conservatively. | Internal SRAM | ~80 KB | mbedtls runtime (TLS handshake working set) | | PSRAM | ~128 KB | EPD framebuffer (792×272×1 ≈ 27 KB) + shadow + glyph cache | | PSRAM | ~512 KB | rope buffer + edit history headroom | -| PSRAM | ~1.5 MB | libgit2 working set during push (pack delta, etc.) | +| PSRAM | ~1.5 MB | libgit2 working set during push (pack delta, etc.) | | PSRAM | rest | heap headroom | PSRAM is the default for `Box::new` via a custom allocator wrapper; DMA-able @@ -354,13 +354,13 @@ does not implement key rotation. Mirrored as live conflicts in [qfd.md §7 "Conflicts left explicitly unresolved by v0.1"](qfd.md#7-tradeoffs-and-their-why-linked-to-adrs). -| Risk | Symptom we'd see | Fallback | -| ---------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- | -| ~~`gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls~~ **fired 2026-07-05**: gix has no HTTP(S) push at all (only `file://`/`ssh://`) | spike 7 (desktop) | **switched to `libgit2` (`git2`)** — mechanics proven on desktop; on-device libgit2/mbedtls cross-compile is the new gate. See [postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). | -| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 | -| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts | -| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push | -| SD + EPD on same SPI bus collide | corruption on save during render | **ADOPTED (ADR-012):** SD on its own SPI3 host | +| Risk | Symptom we'd see | Fallback | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ~~`gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls~~ **fired 2026-07-05**: gix has no HTTP(S) push at all (only `file://`/`ssh://`) | spike 7 (desktop) | **switched to `libgit2` (`git2`)** — mechanics proven on desktop; on-device libgit2/mbedtls cross-compile is the new gate. See [postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). | +| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 | +| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts | +| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push | +| SD + EPD on same SPI bus collide | corruption on save during render | **ADOPTED (ADR-012):** SD on its own SPI3 host | Every one of these is detected by a spike before integration starts — we are not finding them at the end. diff --git a/docs/v0.3-editing.md b/docs/v0.3-editing.md index 1bf8ef9..ec89fd4 100644 --- a/docs/v0.3-editing.md +++ b/docs/v0.3-editing.md @@ -25,6 +25,6 @@ re-confirm on panel). - [x] Ahead of schedule: `c` change operator + text objects (`ciw`, `di(`, `ca"`, … — inner/around, nesting-aware) -Known limits (deferred): `.` drops a *leading* count (`3x` then `.` deletes one; +Known limits (deferred): `.` drops a _leading_ count (`3x` then `.` deletes one; a count inside an operator like `d2w` is kept); no named registers; `.` after an aborted operator (`d`) is a no-op. diff --git a/docs/v0.4-visual-and-ex.md b/docs/v0.4-visual-and-ex.md index 5bbd534..f264d9c 100644 --- a/docs/v0.4-visual-and-ex.md +++ b/docs/v0.4-visual-and-ex.md @@ -11,7 +11,7 @@ register linewise (so `Vy`…`p` copies a line, `Vd` deletes it like `dd`). Moti (`h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d/u`) and counts extend the selection; `v`/`V` toggle/switch submode, `Esc` cancels. The selection renders as reverse-video cells (black fill, glyphs redrawn white) — the only selection -affordance on a 1-bit panel — with the caret cell punched back to *normal* video +affordance on a 1-bit panel — with the caret cell punched back to _normal_ video so the active end stands out. The Normal-mode motions were factored into a shared `move_by` helper so Normal and Visual can't drift. @@ -24,11 +24,11 @@ pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual. 2026-07-11 (18 new tests). Known limits (deferred): no `o` swap-ends, no `x`/`s` operator aliases, no Visual `.` repeat, no `:'<,'>` range commands. - [~] `:` command line (mechanism ✓; `:w`/`:wq`/`:x` save, `:fmt`/`:sync`/`:gl` - wired; `:q` deliberately dropped — nothing to quit to). Command-line - editing added 2026-07-11: Ctrl-W deletes the previous word, Cmd-Backspace - clears the line. **`:e ` deferred to [v0.5](v0.5-palette-and-multi-file.md)** — opening another file - needs host file-IO + buffer switching, which is v0.5's multi-file work - (gated behind Spikes 11/14); half-building it here ahead of its - dirty-buffer handling wasn't worth it. + wired; `:q` deliberately dropped — nothing to quit to). Command-line + editing added 2026-07-11: Ctrl-W deletes the previous word, Cmd-Backspace + clears the line. **`:e ` deferred to [v0.5](v0.5-palette-and-multi-file.md)** — opening another file + needs host file-IO + buffer switching, which is v0.5's multi-file work + (gated behind Spikes 11/14); half-building it here ahead of its + dirty-buffer handling wasn't worth it. - [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter (table alignment, blank-line collapse, trailing-whitespace strip) diff --git a/docs/v0.5-palette-and-multi-file.md b/docs/v0.5-palette-and-multi-file.md index f375cd7..bd5bfe2 100644 --- a/docs/v0.5-palette-and-multi-file.md +++ b/docs/v0.5-palette-and-multi-file.md @@ -9,7 +9,7 @@ host-tested; the palette + transient panel (Spike 11) and delete → git-staging (Spike 14) remain the on-device gates. The single-file `Effect` return became a drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` / `Pull`), so one action can ask the host for several steps in order — opening a -non-resident file queues a `Save` of the outgoing dirty buffer *then* a `Load` of +non-resident file queues a `Save` of the outgoing dirty buffer _then_ a `Load` of the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite: the active buffer keeps its fields inline on `Editor`, inactive buffers park in a small LRU `Vec` (≤ 3 resident = active + 2), and a switch marshals fields @@ -104,15 +104,15 @@ DEVICE 2026-07-12** — a `.typoena.toml` in `typoena-test` with non-default val auto_sync: "5m" }` at boot, a byte-exact parse (comments skipped, bools + quoted string read). **Full gate CLOSED 2026-07-12:** the palette `>` live-toggle round-trip is confirmed — origin's `.typoena.toml` went `line_numbers` false → -true via a *device*-authored publish (`3c79f38`), proving toggle → `SavePrefs` → +true via a _device_-authored publish (`3c79f38`), proving toggle → `SavePrefs` → atomic write → `git add -A` → push — and the `save_on_idle` autosave works on device too. **v0.5 slice 4 fully DONE + on-device confirmed.** **Amendment 2026-07-12 — non-boolean prefs (`theme`, `auto_sync`).** Two of the "decide before build" calls above are **superseded**: `auto_sync` is now a live palette command, and a new `theme` (`light`/`dark`) key ships. The generalising -idea is that a boolean toggle is just the two-option case of *rotate through a -preset list on Enter* — so the palette gains one uniform gesture: **Enter +idea is that a boolean toggle is just the two-option case of _rotate through a +preset list on Enter_ — so the palette gains one uniform gesture: **Enter advances the selected pref to its next value and wraps** (a bool flips; a string pref cycles its options). `theme` rotates `light`↔`dark` and is applied by a single whole-frame invert at the end of the render ([`Frame::invert`]), so text, @@ -127,14 +127,14 @@ helper, and hand-editing the TOML can still set any value (the palette only cycles presets; an off-list value snaps to the head on the next Enter). Editor tests cover the rotate/wrap/snap, the live theme invert, and the round-trip. -**Trailing-newline handling — a saved note ends with a *visible* blank line +**Trailing-newline handling — a saved note ends with a _visible_ blank line (commit `d14d9e7`, 2026-07-12; host-tested, on-device gate open).** Two adjustments to how the buffer meets the file. First, format-on-save no longer -strips *every* trailing blank line — it collapses a run to **at most one** and +strips _every_ trailing blank line — it collapses a run to **at most one** and keeps it, so a writer who presses Enter to open the next line doesn't have that line (and the caret) yanked away on save (the caret used to jump up to the last non-empty line). Second, persistence treats the file's POSIX terminator as -content the editor *shows*: `load_path` reads the file **verbatim** and +content the editor _shows_: `load_path` reads the file **verbatim** and `save_path` writes the buffer, appending a final newline **only if one is missing** (guarded, not unconditional). Because the editor lays out `rows = #\n + 1`, that terminating newline renders as a **visible trailing empty @@ -152,7 +152,7 @@ one. **Amendment 2026-07-13 — recursive enumeration + a 2-char search threshold.** Loading a real repo (`jcalixte/notes`) exposed that `enumerate_files` listed only the **top-level** files of `/sd/repo` and `/sd/local` — a nested notes tree -showed a single file in the palette (subpaths always *opened* fine via +showed a single file in the palette (subpaths always _opened_ fine via `:e repo/sub/x.md`; only the listing was flat). The enumeration is now a recursive walk: dot entries are skipped at every level (so `.git` is never descended into), each directory is read fully before recursing (one FatFS dir @@ -183,8 +183,7 @@ from the same boot log, both already instrumented: `main.rs`). The 1098 path Strings are each below the 16 KB SPIRAM malloc threshold, so they all land in internal DRAM — estimated 60–70 KB, competing with Wi-Fi/TLS. Decision rule: **~60–70 KB - confirms** interning the paths into one shared buffer (a single - >16 KB alloc goes to PSRAM; only a ~9 KB offset index stays in DRAM); + confirms** interning the paths into one shared buffer (a single >16 KB alloc goes to PSRAM; only a ~9 KB offset index stays in DRAM); **well under that (≤~30 KB)** kills the idea — the next DRAM suspect is then parked-buffer text. @@ -193,9 +192,9 @@ from the same boot log, both already instrumented: ghosting on the transient panel); scope shows as the inline `repo/…` / `local/…` label instead of a `[git]`/`[local]` badge. - [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the - ≤ 3 LRU-resident model with dirty-aware save-before-evict done in core** - (host-tested); `:e ` **and the palette** drive it today. Explicit - **close** still to come. + ≤ 3 LRU-resident model with dirty-aware save-before-evict done in core** + (host-tested); `:e ` **and the palette** drive it today. Explicit + **close** still to come. - [x] `:e` and palette share the same recent-files list — both open via `open_path`, which pushes to the in-core MRU that orders the palette. - [x] `:enew` creates a new file — **done in core (host-tested) 2026-07-12.** @@ -213,9 +212,9 @@ from the same boot log, both already instrumented: **Verified on device 2026-07-12** — the `:enew`→`:sync`→`:delete`→`:sync` cycle removed test.txt from origin. - [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope — - **`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish - unavailable (Local)"); the side-panel affordance that hides/greys the - gesture is the remaining half. + **`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish + unavailable (Local)"); the side-panel affordance that hides/greys the + gesture is the remaining half. - [ ] The side panel 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 @@ -229,7 +228,7 @@ from the same boot log, both already instrumented: [`typoena-toml.md`](typoena-toml.md). Keys: - [x] `save_on_idle` (bool, default `true`) — auto-save the current buffer on the idle typing-pause, so `:w` becomes optional rather than required. - **Honoured host-side** as a *silent* save (no snackbar, no forced e-ink + **Honoured host-side** as a _silent_ save (no snackbar, no forced e-ink flash — a safety net, not an action), unformatted, once per typing burst after a 1.5 s pause. - [x] `format_on_save` (bool, default `true`) — run `:fmt` (table alignment, @@ -252,7 +251,7 @@ from the same boot log, both already instrumented: partial-refreshes ghost more on e-paper than `light` — verify on panel. - [ ] `auto_sync` (duration string, default `"10m"`; `"0"` / omitted disables; **min clamp ~`"2m"`** so a palette typo can't drain the - battery) — a *max-staleness cap*, not a wall-clock timer: + battery) — a _max-staleness cap_, not a wall-clock timer: **opportunistic, rate-limited** Publish. Push when already awake + dirty (coalesced into the idle-pause, ≤ once per `auto_sync`) and once on the way into sleep if dirty; **never wake from deep sleep purely to sync**. diff --git a/docs/v0.6-markdown.md b/docs/v0.6-markdown.md index d551a6e..1b9afc7 100644 --- a/docs/v0.6-markdown.md +++ b/docs/v0.6-markdown.md @@ -15,13 +15,14 @@ file format. - [x] Heading lines bolded in render (faux-bold double-strike) - [x] List continuation on Enter inside `- ` / `1. ` (with empty-item exit) - [x] Soft-wrap at word boundaries + ## Snippets Trigger-driven text expansion for Markdown authoring (Zed-inspired, but **no completion popup**: e-ink's ~630 ms refresh rules out a live filtering menu, and it fights the distraction-free premise). The library is a git-synced, Zed-compatible JSON file — full file-format reference in -[`typoena-snippets.md`](typoena-snippets.md). This section is the *editor* +[`typoena-snippets.md`](typoena-snippets.md). This section is the _editor_ behaviour. - [x] **The tab-stop engine** — the shared core both surfaces drive. A body is @@ -67,8 +68,8 @@ v0.5 shipped `Cmd-P` = files and `>` = a five-entry settings list (`save_on_idle makes the sigils a clean split by verb, and the empty-palette placeholder legends them: `Go to file · > settings · $ snippets`. -- **bare `Cmd-P`** → *navigate*: go to file (unchanged). -- **`>`** → *act on the editor* — the command palette, a real action registry (the +- **bare `Cmd-P`** → _navigate_: go to file (unchanged). +- **`>`** → _act on the editor_ — the command palette, a real action registry (the `PALETTE_CMDS` list, actions first then settings). The pref toggles are just its stateful entries, not a special section. Dispatch is by `PaletteCmd::kind`: a **toggle** flips and the list **stays open**; a **one-shot** (`format`, @@ -78,7 +79,7 @@ them: `Go to file · > settings · $ snippets`. from a `repo/`/`local/` prefix as `:enew` does; backspacing past the start steps back to the `>` list). This **retired `:e`** — bare `Cmd-P` covers file-opening; dotfiles can get a dedicated `> edit ...` command if/when wanted. -- **`$`** → *insert content* — the snippet launcher above. +- **`$`** → _insert content_ — the snippet launcher above. Labels avoid `…`/`↹` — the palette/panel fonts are ISO-8859-15, which has neither glyph — so `new file...` uses ASCII dots and the pause hint leads with `»`. @@ -99,14 +100,14 @@ since a distraction-free prose appliance can't render them and you'd never miss them. Prefixes are **English** (except `edanso`, the mnemonic for `œ`), bodies translated from the source Zed snippets. **Three groups, 17 snippets:** - - [x] **Symbols** (inline, the keyboard can't type them): `arrow`→`→`, - `neq`→`≠`, `times`→`×`, `middot`→`·`, `deg`→`°`, `euro`→`€`, `edanso`→`œ` - (dead keys in v0.2.5 cover accents, *not* these). **Caveat:** `→` (U+2192) - and `≠` (U+2260) are outside ISO-8859-15, so they store correctly and sync - but render as a missing-glyph box on the device panel until the font is - extended; the other five are Latin-9 and draw fine. Kept deliberately. - - [x] **Structure**: `todo`→`- [ ] `, `link`→`[$1]($2)$0`, `img`→`![$1]($2)$0`, - `table` (2-col), `code` (fenced block). `img`/`code` are net-new. - - [x] **Prose / PKM templates** (`${n:label}` stripped to `$n`): `booknotes`, - `reference` (the English reference block — `refangl` folded in), `bias`, - `capture`, `standard`. (`5w1h` dropped.) +- [x] **Symbols** (inline, the keyboard can't type them): `arrow`→`→`, + `neq`→`≠`, `times`→`×`, `middot`→`·`, `deg`→`°`, `euro`→`€`, `edanso`→`œ` + (dead keys in v0.2.5 cover accents, _not_ these). **Caveat:** `→` (U+2192) + and `≠` (U+2260) are outside ISO-8859-15, so they store correctly and sync + but render as a missing-glyph box on the device panel until the font is + extended; the other five are Latin-9 and draw fine. Kept deliberately. +- [x] **Structure**: `todo`→`- [ ] `, `link`→`[$1]($2)$0`, `img`→`![$1]($2)$0`, + `table` (2-col), `code` (fenced block). `img`/`code` are net-new. +- [x] **Prose / PKM templates** (`${n:label}` stripped to `$n`): `booknotes`, + `reference` (the English reference block — `refangl` folded in), `bias`, + `capture`, `standard`. (`5w1h` dropped.) diff --git a/docs/v0.7-search-and-git.md b/docs/v0.7-search-and-git.md index 2d30bdb..3dfda18 100644 --- a/docs/v0.7-search-and-git.md +++ b/docs/v0.7-search-and-git.md @@ -69,7 +69,7 @@ the tree was unchanged). - UI gate: `:gl` is refused while the dirty journal is non-empty ("unsynced changes - :sync first", also logged) — an unpublished save would fight the apply, and sync-then-pull is the single-writer appliance's - natural order. A RAM-dirty (never-saved) buffer does *not* gate: its edits + natural order. A RAM-dirty (never-saved) buffer does _not_ gate: its edits simply win over the pulled state (last-writer-wins, like the reconcile). - After a `Pulled` outcome the UI drops clean parked buffers (next switch re-reads the disk), re-reads the clean active buffer in place @@ -108,24 +108,24 @@ Sync performance — inherited from the 24.1 s end-to-end; target ≤ ~10 s): - [~] Close the publish gap: 19.8 s at the kaizen close — the splice's 5.0 s - and the ~7 s of repo-open/negotiation overhead are the next curves - ([tradeoff analysis](tradeoff-curves/sync-commit-staging.md)). TLS - session resumption (above) attacks the negotiation half; re-measure a - warm `:sync` before hunting further. Run-3 data point: a rejected-push - `:sync` (worst case, three connections) totalled 24.0 s vs 59 s in - run 1. **Warm clean publish measured 2026-07-14 (run 4, as `:gp` after - the rename): 19.1 s** — but the mix shifted entirely. The push leg - collapsed to **5.9 s** (6 objects; TLS-resumed connect 2.4 s vs 4.0 s - for the session's first; post-repack marking cost invisible), and the - splice grew to **10.3 s** because the committed file sat four - directories deep: O(depth) means ~5 loose tree writes instead of 1 - (× the ~0.4 s dir-scan cost above) plus the session's first pack touch - (5 mmaps / 1808 KB — the 1740 KB .idx map dominates). Same run: - up-to-date `:gl` is now **4.7 s** git-side via the ls-refs fast path - ("no fetch" — was 9.7 s in run 1). So the residual curve is now - **splice depth × loose-write cost**, which is the same FAT-dir-scan - story as above and shares its levers (pack-not-loose foremost) — a - future perf pass; a root-level file's warm `:gp` should sit ≈ 12–13 s. + and the ~7 s of repo-open/negotiation overhead are the next curves + ([tradeoff analysis](tradeoff-curves/sync-commit-staging.md)). TLS + session resumption (above) attacks the negotiation half; re-measure a + warm `:sync` before hunting further. Run-3 data point: a rejected-push + `:sync` (worst case, three connections) totalled 24.0 s vs 59 s in + run 1. **Warm clean publish measured 2026-07-14 (run 4, as `:gp` after + the rename): 19.1 s** — but the mix shifted entirely. The push leg + collapsed to **5.9 s** (6 objects; TLS-resumed connect 2.4 s vs 4.0 s + for the session's first; post-repack marking cost invisible), and the + splice grew to **10.3 s** because the committed file sat four + directories deep: O(depth) means ~5 loose tree writes instead of 1 + (× the ~0.4 s dir-scan cost above) plus the session's first pack touch + (5 mmaps / 1808 KB — the 1740 KB .idx map dominates). Same run: + up-to-date `:gl` is now **4.7 s** git-side via the ls-refs fast path + ("no fetch" — was 9.7 s in run 1). So the residual curve is now + **splice depth × loose-write cost**, which is the same FAT-dir-scan + story as above and shares its levers (pack-not-loose foremost) — a + future perf pass; a root-level file's warm `:gp` should sit ≈ 12–13 s. - [x] Instrument the residual ~360 ms/loose-write — **measured 2026-07-14, CONVICTED: FAT linear directory scans** (full analysis in the [tradeoff doc](tradeoff-curves/sync-commit-staging.md), run 6). diff --git a/docs/v0.9-robustness.md b/docs/v0.9-robustness.md index 3bc523c..9e46cd1 100644 --- a/docs/v0.9-robustness.md +++ b/docs/v0.9-robustness.md @@ -11,7 +11,7 @@ open there) - [ ] Recover from interrupted push (re-attempt on next save) — the splice journal + stranded-commit recovery (2026-07-13) already survive a - power-pull mid-push; this item is the *retry* half + power-pull mid-push; this item is the _retry_ half - [ ] Reconnect-on-stale-connection in the libgit2 http/stream layer — the push keep-alive race is **avoided, not fixed** (repack keeps the marking phase at ~3.5 s ≪ the ~30 s idle window; a multi-pack or cold-cache diff --git a/firmware/.cargo/config.toml b/firmware/.cargo/config.toml index 3175e3a..57da66d 100644 --- a/firmware/.cargo/config.toml +++ b/firmware/.cargo/config.toml @@ -4,7 +4,7 @@ target = "xtensa-esp32s3-espidf" [target.'cfg(target_os = "espidf")'] linker = "ldproxy" runner = "espflash flash --monitor --baud 921600" -rustflags = [ "--cfg", "espidf_time64"] +rustflags = ["--cfg", "espidf_time64"] [unstable] build-std = ["std", "panic_abort"] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index acd811f..5e86d1c 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -92,7 +92,7 @@ required-features = ["git"] opt-level = "s" [profile.dev] -debug = true # Symbols are nice, and they don't increase the size on Flash +debug = true # Symbols are nice, and they don't increase the size on Flash opt-level = "z" [patch.crates-io] esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys.git" } @@ -129,7 +129,11 @@ git2 = { version = "0.20", default-features = false, optional = true } # (GIT_OPT_SET_CACHE_MAX_SIZE) and cache telemetry (GIT_OPT_GET_CACHED_MEMORY) # that git_sync sets against the run-4 push OOM (2026-07-13). libgit2-sys = { version = "0.18", default-features = false, optional = true } -esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] } +esp-idf-svc = { version = "0.52.1", features = [ + "critical-section", + "embassy-time-driver", + "embassy-sync", +] } # Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor` embassy-time = { version = "0.5", features = ["generic-queue-8"] } embedded-graphics = "0.8" diff --git a/firmware/README.md b/firmware/README.md index c79a656..b8fe6c5 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -86,7 +86,7 @@ atomic round-trip byte-identical. Two findings baked into the code: - **FatFS rename ≠ POSIX rename.** `f_rename` won't overwrite an existing target (returns `FR_EXIST`), so the atomic save unlinks the destination first. `firmware::persistence` pairs this with `*.tmp` boot-recovery - (`Storage::recover`): if a `*.tmp` is found *alongside* the target the crash + (`Storage::recover`): if a `*.tmp` is found _alongside_ the target the crash may have been mid-write, so it keeps the committed file and discards the tmp; it only promotes the tmp when the target was already unlinked. Long filenames (`CONFIG_FATFS_LFN_HEAP`) are required for the two-dot `*.md.tmp` name. @@ -196,10 +196,10 @@ a switch. The nominal build turns it on (it's the product); a **light** build leaves it off — ideal for iterating on the editor, EPD, USB, or SD without paying for libgit2: -| Build | Command | libgit2 component | `git2` crate | `:gp` | -| ----- | ------- | ----------------- | ------------ | ------- | -| **Full / git** (default) | `just build` / `just flash` | compiled | linked | save → push | -| **Light** | `just build-light` / `just flash-light` | not compiled (empty no-op) | not linked | saves locally, skips push | +| Build | Command | libgit2 component | `git2` crate | `:gp` | +| ------------------------ | --------------------------------------- | -------------------------- | ------------ | ------------------------- | +| **Full / git** (default) | `just build` / `just flash` | compiled | linked | save → push | +| **Light** | `just build-light` / `just flash-light` | not compiled (empty no-op) | not linked | saves locally, skips push | Two independent switches make this work, and the justfile flips them together: @@ -209,7 +209,7 @@ Two independent switches make this work, and the justfile flips them together: pass it. 2. **`LIBGIT2_SRC` env** — the [libgit2 component](components/libgit2/CMakeLists.txt) only compiles its sources when this points at the vendored tree; unset, it - registers an *empty* component. Only the full recipes set it. + registers an _empty_ component. Only the full recipes set it. Because git code in the firmware binary is only ever compiled under `--features git`, `just build-light` can never drag libgit2 in. (Git isn't wired @@ -258,14 +258,14 @@ since a wrong guess would let `rsync --delete` wipe the wrong disk's `repo/`. a ladder — `.env` if present, else derived from tools already on the machine, else an interactive prompt with the derived value as the default: -| Value | Derived from | -| --- | --- | -| `TW_REMOTE_URL` | the source repo's `origin` (or the card's existing clone) | -| `TW_AUTHOR_NAME` / `TW_AUTHOR_EMAIL` | `git config user.name` / `user.email` | -| `TW_GH_USER` | `gh api user` | -| `TW_WIFI_SSID` | the Mac's active Wi-Fi network | -| `TW_WIFI_PASS` | the System keychain for that SSID (else prompt) | -| `TW_PAT` | **never derived** — always typed by hand | +| Value | Derived from | +| ------------------------------------ | --------------------------------------------------------- | +| `TW_REMOTE_URL` | the source repo's `origin` (or the card's existing clone) | +| `TW_AUTHOR_NAME` / `TW_AUTHOR_EMAIL` | `git config user.name` / `user.email` | +| `TW_GH_USER` | `gh api user` | +| `TW_WIFI_SSID` | the Mac's active Wi-Fi network | +| `TW_WIFI_PASS` | the System keychain for that SSID (else prompt) | +| `TW_PAT` | **never derived** — always typed by hand | So a first run is usually: `just init ~/code/notes`, press Enter through the auto-filled defaults, approve the macOS Keychain dialog for the Wi-Fi password diff --git a/hardware/case/concept.html b/hardware/case/concept.html index bd3411d..d2af0a6 100644 --- a/hardware/case/concept.html +++ b/hardware/case/concept.html @@ -1,255 +1,671 @@ Typoena — Enclosure Concept
-
Typoena
Enclosure concept · typewriter body
-
Panel
GDEY0579T93
Board
ESP32-S3
-
Units
mm
Scale
to-fit
-
Rev
v0 · concept
Date
2026-07-11
+
+
Panel
+
GDEY0579T93
+
Board
+
ESP32-S3
+
+
+
Units
+
mm
+
Scale
+
to-fit
+
+
+
Rev
+
v0 · concept
+
Date
+
2026-07-11
+
-

The screen is the sheet of paper. A shallow sage wedge with a reclined deck; the e-paper strip sits where a typewriter's page would be, a decorative platen roller and two knobs ride the back edge, and the keyboard you bring rests in front. Every dimension below is from the real datasheets — this is the shape to react to before I cut the parametric model.

+

+ The screen is the sheet of paper. A shallow + sage wedge with a reclined deck; the e-paper strip sits where a + typewriter's page would be, a decorative platen roller and two knobs + ride the back edge, and the keyboard you bring rests in front. Every + dimension below is from the real datasheets — this is the shape to react + to before I cut the parametric model. +

- body - platen / deck / feet - e-paper - dimensions + body + platen / deck / + feet + e-paper + dimensions

Side profile — the silhouette

-

The money view: shallow deck reclined ~18°, platen & knob at the back, ports on the tail, your keyboard ghosted in front.

- +

+ The money view: shallow deck reclined ~18°, platen & knob at the + back, ports on the tail, your keyboard ghosted in front. +

+

Front elevation — width

-

Letterbox screen across the deck; knobs poke past the sides.

- +

+ Letterbox screen across the deck; knobs poke past the sides. +

+

Top plan — deck layout

-

Screen forward, platen band aft, ports along the back edge.

- +

+ Screen forward, platen band aft, ports along the back edge. +

+
-

01 Cheap-ish body

You picked the higher-material form, but it stays a shell: ~2.4 mm walls, open bottom, a screwed baseplate. Hollow inside, no infill spent on a solid block.

-

02 Color does the work

Print the body in matte sage, platen & knobs in cream or black. Two-tone via a filament change reads unmistakably "typewriter" for free.

-

03 Decorative cues

Platen roller + two knobs are cosmetic (your call) — printed separately, no wiring, no GPIO. Round feet finish the machined look.

-

04 Fragile glass

The 1 mm panel drops into a rebate behind a bezel lip that overlaps ~6 mm; the FPC folds back to the DESPI-C579 breakout in the cavity.

-

05 The one tunable

Deck angle is the ergonomics dial. 18° is typewriter-shallow; 28–35° reads better seated. Say the word and I'll steepen it.

-

06 Optional lid

Your docs say "hinged lid" — a cover latching over the deck echoes a portable typewriter's case and guards the glass in a bag. Toggled off by default.

+
+

01 Cheap-ish body

+

+ You picked the higher-material form, but it stays a shell: + ~2.4 mm walls, open bottom, a screwed baseplate. Hollow inside, + no infill spent on a solid block. +

+
+
+

02 Color does the work

+

+ Print the body in matte sage, platen & knobs in cream or + black. Two-tone via a filament change reads unmistakably "typewriter" + for free. +

+
+
+

03 Decorative cues

+

+ Platen roller + two knobs are cosmetic (your call) — printed + separately, no wiring, no GPIO. Round feet finish the machined look. +

+
+
+

04 Fragile glass

+

+ The 1 mm panel drops into a rebate behind a bezel lip that + overlaps ~6 mm; the FPC folds back to the DESPI-C579 breakout in + the cavity. +

+
+
+

05 The one tunable

+

+ Deck angle is the ergonomics dial. 18° is typewriter-shallow; + 28–35° reads better seated. Say the word and I'll steepen it. +

+
+
+

06 Optional lid

+

+ Your docs say "hinged lid" — a cover latching over the deck echoes a + portable typewriter's case and guards the glass in a bag. Toggled off + by default. +

+
Typoena · writing appliance - Screen 150.9 × 56.9 × 1.0 · active 139.0 × 47.7 · pitch 0.1755 + Screen 150.9 × 56.9 × 1.0 · active 139.0 × 47.7 · pitch 0.1755 Orthographic · third-angle · not for manufacture
-
diff --git a/spikes/spike7-git-push/README.md b/spikes/spike7-git-push/README.md index 9345e31..92b0c43 100644 --- a/spikes/spike7-git-push/README.md +++ b/spikes/spike7-git-push/README.md @@ -14,8 +14,8 @@ toolchain), deliberately kept out of the xtensa-pinned `firmware/` crate. ## Headline finding: the ADR-004 kill-switch fired -Spike 7 is the documented kill-switch for [ADR-004](../../docs/adr.md): *"if -[gix smart-HTTP push] fails on the device, we fall back to `libgit2-sys`."* +Spike 7 is the documented kill-switch for [ADR-004](../../docs/adr.md): _"if +[gix smart-HTTP push] fails on the device, we fall back to `libgit2-sys`."_ It fires at the **library level**, before any device work: gitoxide's own crate-status doc states `gix` supports push only over `file://` and `ssh://` — **push over HTTP(S) is not implemented** (only clone/fetch are). Since @@ -31,7 +31,7 @@ Mirrors the v0.1 `git` module contract: 2. stage with `git add --all` semantics (**deletions propagate** — needed for v0.5 file-delete) 3. short-circuit when nothing is staged → "nothing to publish" -4. commit; author from config, message = an ISO-8601 timestamp (the time *is* +4. commit; author from config, message = an ISO-8601 timestamp (the time _is_ the message) 5. push `HEAD` to `origin/` over HTTPS, PAT in the credential callback (**never logged**) @@ -46,7 +46,7 @@ Run live against a local `file://` bare remote (no credentials): - **first commit + push** from an unborn `HEAD` → lands in origin ✅ - **nothing to publish** short-circuits when the index matches `HEAD` ✅ - **divergence**: a second clone advances origin → push rejected → `pull - --no-edit` merges cleanly → retry push succeeds, origin gets a two-parent +--no-edit` merges cleanly → retry push succeeds, origin gets a two-parent merge commit ✅ - **real HTTPS + PAT push to github.com** — confirmed 2026-07-05 against @@ -60,7 +60,7 @@ fast-forward. The callback path is coded for but unproven live. ## Not proven here (the next gate) -The risk moved *with* the kill-switch: **can `libgit2` cross-compile to +The risk moved _with_ the kill-switch: **can `libgit2` cross-compile to `xtensa-esp32s3-espidf` against esp-idf's mbedtls?** — the exact C cross-compile pain gix was chosen to avoid. That is the next on-device spike, and it also needs PSRAM (`CONFIG_SPIRAM`) enabled and a working SD card (Spike 3) for the