style: run oxfmt repo-wide
First full pass of the pnpm fmt toolchain: markdown table alignment, TOML array wrapping, CSS/HTML expansion in the enclosure concept page. No content changes; mermaid blocks untouched.
This commit is contained in:
31
docs/adr.md
31
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <repo-path>` — 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<poll.h>` (only `<sys/poll.h>`) | forwarding `poll.h` on the include path |
|
||||
| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` |
|
||||
| `<sys/mman.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 `<poll.h>` (only `<sys/poll.h>`) | forwarding `poll.h` on the include path |
|
||||
| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` |
|
||||
| `<sys/mman.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<slug>.md`. These capture *why* a spike stalled or a design turned
|
||||
> `YYYY-MM-DD-<slug>.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 |
|
||||
|
||||
127
docs/qfd.md
127
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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Oid>) -> Result<Oid>
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: <reason>`, 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
|
||||
|
||||
@@ -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<Mutex<EditorState>>`; 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.
|
||||
|
||||
@@ -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<Esc>`) is a no-op.
|
||||
|
||||
@@ -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 <path>` 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 <path>` 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)
|
||||
|
||||
@@ -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<Buffer>` (≤ 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 <path>` **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 <path>` **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**.
|
||||
|
||||
@@ -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`→`$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`→`$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.)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user