Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
b7a0aa7c5c docs: add "Durability before delivery" principle to Publish UX
The user-meaningful moment of a Publish is when the local commit
lands (~0.2 s), not when the push completes (~5–10 s). New principle
in CONTEXT.md, instantiated in v0.1 product doc with staged status
transitions and a corrected on-demand Wi-Fi status example.
2026-05-17 12:54:09 +02:00
Julien Calixte
011f79f134 docs(notes): add Ctrl-G perceived-latency essay
Long-form rationale for the "durability before delivery" framing —
why a 10-second network operation in response to a single keystroke
doesn't have to feel slow. Establishes docs/notes/ as the home for
shareable design rationale (not authoritative spec).
2026-05-17 12:54:04 +02:00
3 changed files with 174 additions and 11 deletions

View File

@@ -92,6 +92,12 @@ into user-facing language).
detail the device authors itself. Branches are out of scope for the same
reason — the device tracks one linear stream of work on whichever branch
the remote was cloned on, and never switches.
- **Durability before delivery.** A **Publish**'s user-meaningful moment is
when the local commit lands (~0.2 s), not when the push completes
(~510 s). The status line surfaces the commit-landed state as soon as
it exists; the remaining push time is the transport of an already-safe
thing. Long-form rationale:
[`docs/notes/ctrl-g-perceived-latency.md`](docs/notes/ctrl-g-perceived-latency.md).
- **No state the user didn't ask for.** No banners about pending work, no
prompts about divergence, no "did you mean to publish" warnings. The status
line reflects the _current_ action's outcome, nothing else.

View File

@@ -0,0 +1,151 @@
# Durability before delivery
> Why a 10-second keystroke on a typewriter I'm building doesn't have to
> feel slow.
## Two keystrokes, very different costs
I'm building a small device called **Typoena**: an e-ink panel, a
mechanical keyboard, an ESP32-S3, and a single purpose. You open the lid,
you write Markdown, and you press a key to publish it to GitHub. There is
no browser, no notification tray, no second app. The hardware enforces
focus the way software can't.
The whole product surface is two user-facing actions:
- **Save** (`Ctrl-S`) — write the current buffer to the SD card. Always
available. ~200 ms.
- **Publish** (`Ctrl-G`) — ship the entire tracked working copy to the git
remote. Atomic from the user's view. **510 seconds typical.**
These two keystrokes _look_ symmetric. Both are modifier-letter, both
triggered the same way, both single-shot. But one of them takes 200 ms
and the other takes 510 seconds. That's a 50× cost gap, and it matters
because the human perception threshold for "instant" is about 100 ms.
The physical keystroke itself — key down, debounce, USB report, key up —
takes ~150 ms. On `Ctrl-S` that's _most_ of the perceived time. On
`Ctrl-G`, the keystroke is barely the first sliver of a long process.
## Where the time goes
Here's a breakdown of `Ctrl-G` on a fresh session, with the Wi-Fi radio
starting cold:
| Stage | Time |
| --------------------------------- | --------- |
| Save buffer to SD | ~0.1 s |
| `git add` + `git commit` | ~0.2 s |
| Wi-Fi associate + DHCP | 25 s |
| TLS handshake | ~2 s |
| `git push` (pack + send + server) | 13 s |
| **Total (critical path)** | **510 s** |
Wi-Fi association dominates, and that's deliberate. Typoena's radio is
**off by default** — it only powers up when `Ctrl-G` is pressed, and it
shuts down again after a short grace window. The battery savings are
dramatic: always-on station mode would burn ~410 mAh/day on the radio
alone; on-demand drops that to ~25 mAh/day at ten Publishes per day. On a
single 18650 cell, that's the difference between days and weeks of
standby.
The price is paid on every cold Publish. A few seconds, every time. For
a device that, by design, doesn't need to be online except when shipping
work, this trade is fine. But it produces the asymmetry above: one
keystroke costs you a fifth of a second; another costs you ten.
## The question I was sitting with
After mapping all this out, I asked the question that triggered the rest
of this post:
> "I'm concerned about the fact that Ctrl-G is a 150ms action to do, but
> what it triggers can take >5-10s. Compared to the same quick action
> Ctrl-S for instance that will have a order of magnitude even lower than
> the pressing key action."
My first instinct was to optimise. TLS session resumption could shave a
second. A smaller cipher suite, another. Static IP instead of DHCP, a
few hundred milliseconds. With effort, I might cut the cold path to
45 seconds.
But that's still 25× the `Ctrl-S` cost, and every optimisation comes
with friction. TLS resumption requires storing session tickets across
radio power-cycles (more state, more code). Cipher tuning sacrifices
flexibility on networks I haven't tested. Static IPs are fragile when
the user moves between routers. I'd be spending design budget on a
number that, even halved, still feels slow.
So I asked a different question: **what is the user actually waiting
for?**
## A different question
When you press `Ctrl-S`, the moment you care about is "my work is
saved." The SD card writes the bytes in 50200 ms, and that moment
lines up with the operation completing. Save = safe. Same instant.
When you press `Ctrl-G`, what's the equivalent? You'd naturally say "my
work is published" — and assume that means the push completed. But this
device authors timestamp commits _before_ it pushes. The local commit
lands at ~0.2 seconds, and from that moment on your work is preserved
across power loss, SD removal, the apocalypse — everything except remote
delivery. The remaining 510 seconds is _transport of an already-safe
thing_. The work isn't in flight; it's already committed to disk. The
push is just delivery to a backup location.
## Durability before delivery
This is the design principle the question pushed me toward: **the moment
that matters to the user is the moment durability is achieved, not the
moment delivery completes.** Once I named it, the implementation became
obvious.
The status line surfaces the commit-landed state at ~0.2 seconds, then
shows the push as a secondary state:
```
Bad: "publishing… 1 of 3 ▓░░" ← misleading, conflates safe + delivered
Good: "✓ committed abc1234 · pushing" ← says exactly what's done and what's pending
```
Two transitions, two messages, partial-refresh on the status line only.
The user sees the `✓` within a fifth of a second of pressing the key.
They know the work is safe. They can keep typing. The radio continues
associating, the TLS handshake completes, the push lands — all of it
happens around them, none of it modal.
The _perceived_ latency of `Ctrl-G` collapses from 10 seconds to roughly
200 milliseconds. The gap with `Ctrl-S` is no longer 50×; it's barely
distinguishable.
## Why this generalises
I think this is a useful lens beyond writing appliances. Any application
that does network I/O in response to a single user action has the same
shape: a fast local operation followed by a slow remote one. The usual
responses are:
1. **Optimise the slow part until it feels fast.** Often impossible.
2. **Hide the slow part with a spinner.** Admits defeat — and on e-ink,
with ~300 ms refresh and ghosting, you can't even spin.
3. **Quietly do the operation in the background and not tell the user.**
This is the auto-sync trap I explicitly designed Typoena to avoid —
the device is a writing tool, not a sync engine.
The fourth path — name the durability moment, surface it the instant it
arrives — is almost always available. It shifts the question from "how
fast is the operation" to "when is the user safe." Those are different
questions with different answers, and the second one is almost always
faster.
## What I'm not optimising
I'm not chasing a v1.0 target of `Ctrl-G` in ≤10 seconds on the cold
path. With the safety moment landing at ~200 ms, that target is no
longer the load-bearing UX metric. I'd rather spend the engineering
effort on something the user can actually feel: typing latency,
partial-refresh ghosting, keyboard wake time.
Durability before delivery. Once you see it, you can't unsee it — and
suddenly the slow operations stop feeling slow.

View File

@@ -72,7 +72,7 @@ Auth model rationale: [ADR-005](adr.md#adr-005-auth--https--github-personal-acce
[POWER ON, config present]
Boot (~3 s): Wi-Fi reconnect in background
Boot (~3 s): Wi-Fi stays off (brought up on demand by `Ctrl-G`)
Open /sd/local/notes.md, place cursor at end of file
@@ -82,13 +82,14 @@ Auth model rationale: [ADR-005](adr.md#adr-005-auth--https--github-personal-acce
├── Ctrl-S → write file, full refresh, status: "saved 14:02"
└── Ctrl-G → save if dirty, then:
git add .
└── Ctrl-G → save buffer; in parallel: commit locally + bring Wi-Fi up
(if nothing staged: status "nothing to publish", stop)
git commit -m "<ISO-8601 timestamp>"
git push
└─ on failure: git pull --no-edit, retry push
status: "published abc1234" or "publish failed: <reason>"
git add . && git commit -m "<ISO-8601 timestamp>"
t≈0.2 s, status: "✓ committed abc1234 · pushing…"
once Wi-Fi up: git push
└─ on failure: git pull --no-edit, retry push once
t≈510 s, status: "✓ pushed abc1234" or "publish failed: <reason>"
(local commit preserved either way)
```
## Screen layout
@@ -114,14 +115,19 @@ context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display-
│ │
│ │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ INSERT • unsaved • Wi-Fi │ ← status
│ INSERT • unsaved • Wi-Fi │ ← status
└─────────────────────────────────────────────────────────────────────────────────────┘
```
- Header: filename, dirty indicator (●/○), wall clock if SNTP has set time.
- Status: mode (always `INSERT` in v0.1), save state, Wi-Fi state. After a
`Ctrl-S` / `Ctrl-G`, status briefly shows the action result (saved at /
pushed `abc1234` / error message) then returns to default.
- Status: mode (always `INSERT` in v0.1), save state, Wi-Fi state
(`—` = off, `✓` = connected, `✗` = failed; default is `—` because the
radio is on-demand). `Ctrl-S` briefly flashes "saved at HH:MM" and
returns. `Ctrl-G` transitions through `✓ committed abc1234 · pushing…`
at ~0.2 s — the 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).
- Cursor: solid block at the insertion point. **No blink.**
Fonts: one mono font, embedded in firmware. Target sizing: a 14 px mono