write 0.5.0

A fullscreen, distraction-free, write-only Markdown editor that fades text away to silence the writer's inner editor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# Completed Work

## Task 1a — Window, raw input capture, Markdown file append

Stood up the runnable, borderless, dark, keyboard-only egui/eframe (wgpu backend) app.

- `src/main.rs` — builds `NativeOptions` with a borderless, titlebar-less, fullscreen
  `ViewportBuilder` and calls `eframe::run_native`, returning `eframe::Result`.
- `src/app.rs``WriteApp` implementing `eframe::App` via the 0.34 `ui(&mut self, ui, frame)`
  signature:
  - On launch, creates `dirs::document_dir()/write/<epoch>.md` (falling back to `home_dir`),
    opened append-only and wrapped in a `BufWriter`.
  - Captures raw input each frame from `ctx.input(|i| i.events)`: `Event::Text` appended verbatim;
    Enter → idempotent `"\n\n"`; Backspace → idempotent single space (never deletes); Esc → quit;
    Tab focus navigation suppressed; arrows/Delete/Home/End/PageUp-Down and `command`-modified
    combos (paste/undo) ignored.
  - Durability: flush + `File::sync_data()` every ~30s (wall-clock `Instant`) and a final fsync on
    clean exit via `on_exit`. `request_repaint_after(30s)` keeps the idle fsync firing.
  - Renders a full-rect black background plus a single wrapped galley of the session buffer
    (`Painter::layout``Painter::galley`); `clear_color` overridden to opaque black.

Notes / deviations from the original design sketch:
- `WriteApp::new` returns `Box<dyn Error + Send + Sync>` (eframe's `AppCreator` requires the error
  to be `Send + Sync`).
- Text layout uses `Painter::layout(&self, ...)` instead of `ui.fonts(|f| f.layout(...))`: in
  egui 0.34 the `fonts` reader yields an immutable `&FontsView` whereas `FontsView::layout` needs
  `&mut self`; `Painter::layout` wraps the mutable access internally.
- Used `ctx.content_rect()` (the non-deprecated replacement for `screen_rect()`).

`cargo clippy` and `cargo fmt --check` are clean. No automated tests (GUI loop); the 1a acceptance
criteria are manual.

## Task 1b — The fading 30-second view

Replaced 1a's plain solid-gray render with the real focus view: only the last ~30s of typed text is
visible, fading linearly to fully transparent over ~1s once it crosses the threshold, never
reappearing. Purely visual — the Markdown file and append behavior from 1a are untouched.

- `src/app/render.rs` (new, pure/unit-tested per the `module.rs` + `module/submodule.rs` layout) —
  `Glyph { ch, birth }`; `fade_factor(age)` (opaque ≤30s, linear over the 1s fade, 0 past 31s);
  `quantize_alpha(factor)``u8`; `runs()` coalesces adjacent same-alpha glyphs into
  `(String, u8)` runs; `build_job()` builds a word-wrapped `LayoutJob` with one `TextFormat` section
  per run, fading via `Color32::gamma_multiply` (composite-over-black opacity, not fade-to-gray).
- `src/app.rs` — added `mod render;`, a `visible: VecDeque<Glyph>` render buffer fed by `append`
  (now `append(&mut self, s, now)`), with `now = ctx.input(|i| i.time)` stamped per frame. Each
  frame prunes fully-faded glyphs from the front, then draws a centered, bottom-anchored galley so
  newest text sits near the bottom and older lines scroll off the top. `document` is retained for
  1c's word count.
- CPU idle fix: repaint is now conditional — `request_repaint()` only while glyphs are visible;
  when blank, wake only at the next 30s fsync deadline
  (`request_repaint_after(SYNC_INTERVAL.saturating_sub(last_sync.elapsed()))`), replacing 1a's
  unconditional `request_repaint_after(SYNC_INTERVAL)`.

Notes / deviations:
- Layout goes through `Painter::layout_job(&self, ...)` rather than `ui.fonts(|f| f.layout_job(..))`:
  in egui 0.34 the `fonts` reader yields an immutable `&FontsView` but `FontsView::layout_job` needs
  `&mut self`; the painter wraps the mutable access internally (same pattern as 1a's `layout`).

`cargo clippy`, `cargo fmt`, and the 4 unit tests in `render.rs` (`cargo nextest run`) are clean.
The fade/scroll visuals remain manual acceptance.

## Task 1c — Explicit visible-buffer clears + Esc menu

Added the writer's manual "wipe the screen" controls and a key-driven Esc menu, without ever
removing anything already written to the file (clearing is visual only).

- `src/app/render.rs` — added `clear_factor(age_since_clear)` (immediate linear fade over
  `FADE_SECS`, no 30s hold) and `glyph_factor(birth, now, visibility_head)` which combines a glyph's
  own age-fade with the clear-fade (glyphs born at/after `visibility_head` are unaffected). `runs`
  and `build_job` now take `visibility_head` and use `glyph_factor`. New unit tests:
  `clear_factor_immediate`, `glyph_factor_unaffected_after_head`, `glyph_factor_clears_older_glyphs`.
- `src/app.rs` — added `visibility_head: f64`, replaced `last_was_enter: bool` with an `enter_run: u8`
  state machine (0 → none, 1 → one Enter written `\n\n`, 2+ → cleared/no-op), and added
  `menu_open: bool`. Triggers that advance `visibility_head` to "now" (simultaneous ~1s fade-out):
  Backspace (first of a run; also appends one idempotent space) and a consecutive second Enter (no
  extra newlines). The event loop gates on `menu_open` so the menu is key-driven (Q quits, Esc
  resumes) and typing into the void is ignored. Esc opens the menu and fsyncs. Prune now uses
  `glyph_factor` so clear-faded glyphs drain in birth order. A centered, title-bar-less
  `egui::Window` overlays a live `split_whitespace().count()` word count plus key hints.

All v1 tasks (1a/1b/1c) complete. `cargo clippy`, `cargo nextest run` (7 tests), and
`cargo fmt --check` all clean.

## Cursor-wiggle / visual feedback on backspace-clear

On the first backspace of a run, the visible text now performs a brief, decaying horizontal "shake"
(the classic macOS "no" wiggle) while it fades out, giving immediate kinetic acknowledgment that the
no-delete/wipe gesture was registered. Purely visual — the Markdown file, append behavior, and clear
logic are untouched. Double-Enter clears stay shake-free.

- `src/app/render.rs` — added `SHAKE_SECS`/`SHAKE_AMPLITUDE`/`SHAKE_FREQ` constants and a pure
  `shake_offset(since)`: a sine oscillation (12px peak, ~3 oscillations over 0.4s) under a
  linear-decay envelope, returning 0.0 at rest (outside the `(0, SHAKE_SECS)` window). New unit
  tests `shake_offset_at_rest_outside_window` and `shake_offset_bounded_and_decays`.
- `src/app.rs` — added `shake_start: f64` (init `f64::NEG_INFINITY` so early frames report at-rest),
  set to `now` in the same first-backspace-of-a-run guard that already sets `visibility_head`, and
  offset the galley's x-position by `shake_offset(now - shake_start)`. The offset (≤12px) stays well
  inside the 40px margin, so text never clips; the existing post-backspace fade repaint loop already
  covers the 0.4s shake.

`cargo clippy`, `cargo nextest run` (9 tests), and `cargo fmt --check` all clean.

## Keyboard shortcut to return to windowed (non-fullscreen) mode

Gave the writer a deliberate, non-colliding way to drop the borderless-fullscreen app back to a
windowed (maximized, chrome-hidden) shell so the window can be moved/resized/closed via the OS.
Driven from the Esc menu (the existing key-driven control surface where writing input is already
gated off), so it can never fire during normal prose entry.

- `src/app.rs` — added a `fullscreen: bool` live-state field (init `false`, set `true` in the
  launch-time deferred-entry block alongside `Fullscreen(true)`). `entered_fullscreen` stays a
  **distinct** one-shot deferral latch so a later manual exit to windowed never re-fires the
  deferral. New `egui::Key::W` arm in the menu key handler sends
  `ViewportCommand::Fullscreen(!self.fullscreen)`, flips the bool, and closes the menu to resume
  writing (eframe 0.34 has no toggle variant, so we track the state ourselves). The menu hint label
  is now state-aware: `"W — windowed"` while fullscreen, `"W — fullscreen"` while windowed.

Toggle is reachable only via the Esc menu; typing "w" mid-sentence still produces a literal "w".
GUI window-state, so manual acceptance per project convention. `cargo clippy`, `cargo nextest run`
(9 tests), and `cargo fmt --check` all clean.

## Anchor the text column to the vertical center

Moved the visible galley from a bottom-margin anchor to a center-relative one so the active writing
line holds a fixed focal point near the window's vertical center while older text scrolls upward.

- `src/app.rs` — hoisted the proportional font size into `const FONT_SIZE: f32 = 28.0;` and reused it
  for both the `egui::FontId` and the x-height calc (no duplicated literal). Replaced
  `y = rect.bottom() - margin - galley.size().y` with a center-relative anchor:
  `x_height = 0.5 * FONT_SIZE`, `center_y = rect.center().y`,
  `y = center_y + x_height - galley.size().y`, pinning the galley's bottom edge one x-height below
  the window center. The galley grows upward as lines accumulate; no upward clamp (the 30s fade
  bounds total text). Horizontal layout, `col_width`/`max_col`, and the backspace `shake_offset` dx
  are untouched; `margin` still drives the horizontal column-width calc only.

GUI positioning, so manual acceptance per project convention. `cargo fmt --check`, `cargo clippy`,
and `cargo nextest run` (9 render tests) all clean.

## Persistent word-count indicator (upper-right HUD)

Added an always-visible, small dim-gray **day-total** word count anchored to the upper-right corner,
seeded from the daily file's existing on-disk content so prior same-day sessions are included (the
in-memory `document` mirror still starts empty each launch). It's a write-time HUD, not a read mode.

- `src/app/render.rs` — added a pure `word_count(s: &str)` helper (`split_whitespace().count()`)
  shared by the HUD and the Esc menu so the two always agree and match the on-disk seed. New unit
  test `word_count_counts_whitespace_tokens` (the plan's expected value for the multi-whitespace
  case was off-by-one — that string has 4 tokens, not 3; the test asserts the correct 4).
- `src/app.rs``open_session_file` now returns `Option<(File, usize)>`, computing the seed via
  `word_count(&fs::read_to_string(&dir).unwrap_or_default())` right after the lock succeeds (a read
  error degrades to 0). Added a `seed_words: usize` field, threaded through `WriteApp::new`. After
  the main galley paints, the HUD draws `seed_words + word_count(&document)` as
  `format!("{words} words")` via `Painter::text` with `Align2::RIGHT_TOP` at `0.5 * FONT_SIZE` in
  `Color32::from_gray(110)`, only when `words >= 2`. The Esc-menu count uses the same expression.
- `src/main.rs` — destructures the new `(file, seed_words)` tuple and passes `seed_words` to
  `WriteApp::new`.

The count is monotonic (clears never shrink `document`); no repaint changes needed (it only moves on
input). GUI placement is manual acceptance. `cargo fmt --check`, `cargo clippy`, and
`cargo nextest run` (10 tests) all clean.

## Task 2 — Bounded backspace via file truncation

Softened the "no deletion ever" policy into a strictly bounded real deletion: `Backspace` now
truncates the day file by one whole char, but only within the last two whitespace-delimited words
and never below a monotonic floor that ratchets forward as the writer types. Once text is 2+ words
in the past it is permanently committed. The old append-a-space/clear/shake machinery is removed
from the handler (shake constants/field retained for sub-task 2.1).

- `src/app/render.rs` — added `start_byte_of_second_to_last_word(s)`, a single-pass
  `char_indices` scan returning the byte offset of `words[len-2]` (or `0` for <2 words), matching
  `split_whitespace` semantics. `Glyph` gained an `end_offset: usize` (byte end-of-char within
  `document` at append time) so the visible buffer truncates correctly. New unit tests:
  `start_byte_of_second_to_last_word_cases` (ASCII, leading/trailing whitespace, `\n\n`, multi-byte
  `"café déjà vu"` → byte 6) and `delete_floor_ratchet_is_monotonic` (folds the ratchet over a
  growing/shrinking doc sequence, asserting it never decreases and stays `>= boot_size`).
- `src/app.rs``open_session_file` now also returns `boot_size = file.metadata()?.len()` (return
  type aliased as `SessionFile = (File, usize, u64)` to keep clippy's `type_complexity` quiet).
  `WriteApp` gained `boot_size`/`delete_floor` (both init to `boot_size`) and dropped
  `last_was_backspace`. `append` computes per-glyph `end_offset` and ratchets
  `delete_floor = delete_floor.max(boot_size + start_byte_of_second_to_last_word(&document))`. New
  `fn backspace(&mut self)`: no-op when at/under the floor, else `document.pop()`, flush (mandatory
  before truncate — append-mode `BufWriter`), `set_len(boot_size + document.len())`, prune tail
  glyphs whose `end_offset > new doc len`, reset `enter_run`. The Backspace arm now just calls
  `self.backspace()`; the dead `last_was_backspace = false` assignments were removed from the Text
  and Enter arms.
- `src/main.rs` — destructures the `(file, seed_words, boot_size)` triple and threads `boot_size`
  into `WriteApp::new`.
- `README.md` — softened the intro/closing prose and rewrote the `Backspace` key-table row to
  describe the bounded delete.

`shake_start` field and `shake_offset` render call left in place (dx stays 0) for sub-task 2.1.
`cargo fmt`, `cargo clippy --all-targets`, and `cargo nextest run` (12 tests) all clean.

## Sub-task 2.1 — Wiggle when the backspace limit is hit

Reconnected the existing shake machinery (freed up by Task 2's bounded backspace) to give the writer
a visual "no" cue when a backspace is blocked, replacing the previous silent no-op.

- `src/app.rs``backspace` regained its `now: f64` param. Both blocked early-returns (document
  empty / at the `delete_floor`, and the would-cross-the-floor guard) now set `self.shake_start =
  now` before returning, driving the pre-existing `shake_offset(now - shake_start)` galley dx into a
  brief decaying horizontal wiggle. A *successful* deletion sets nothing, so it stays shake-free. The
  Backspace event arm now calls `self.backspace(now)`, and the stale "sub-task 2.1 will add the
  wiggle" comments were refreshed to describe the implemented behavior.

No new animation code and no new tests: the wiggle is a single field write feeding the already
unit-tested `shake_offset` math (`shake_offset_at_rest_outside_window`,
`shake_offset_bounded_and_decays`), and the render loop already requests continuous repaints while
text is visible so the 0.4s shake animates. `cargo clippy`, `cargo fmt`, and `cargo nextest run`
(12 tests) all clean.

## Tint the still-deletable tail ("fresh" text) toward red

Gave the writer a visual signal of how far back a backspace can reach: the deletable tail (document
bytes `>= delete_floor - boot_size`) now renders with a warmer/redder tint while locked, permanent
text keeps the normal gray body color. The boundary follows the monotonic `delete_floor` (not a
freshly recomputed second-to-last word), so the colored region matches exactly what a subsequent
backspace can remove.

- `src/app/render.rs` — added `FRESH_GB_SCALE` (0.7) and a `redden(Color32)` helper that keeps the
  red channel and attenuates green/blue. `runs` gained a `floor_byte` param and now splits on
  `(alpha, fresh)`, returning a `fresh` flag per run (`fresh = end_offset - ch.len_utf8() >=
  floor_byte`, saturating to avoid underflow). `build_job` gained a trailing `floor_byte` param and
  picks `redden(base)` vs `base` per run *before* `gamma_multiply`, so the tint composes with the
  age-fade (premultiplied-alpha path drains to black) and darker mode.
- `src/app.rs``ui` computes `floor_byte = (delete_floor - boot_size) as usize` (subtraction can't
  underflow: `delete_floor` is initialized to `boot_size` and only ratcheted up) and threads it into
  `build_job`.

Updated `runs_coalesce_same_age` / `runs_split_on_alpha_change` for the new tuple/signature and
added `runs_split_on_fresh_boundary` asserting the split at the floor boundary. `cargo fmt`,
`cargo clippy --all-targets`, and `cargo nextest run` (13 tests) all clean.

## Cursor — solid non-blinking rectangle

Added a passive insertion-point marker: a solid, filled, non-blinking rectangle that always sits at
the tail of the live text, exactly where `append` drops the next glyph.

**This is not a violation of the project's "no cursor movement" rule.** The marker never moves under
user command — there are no arrows, no click-to-position, and no selection. It is purely passive,
riding the end of the current text. The distinction matters: "no cursor movement" forbids the writer
from repositioning an insertion point, not the existence of a marker that follows the tail.

- `src/app.rs` — pure paint inside `WriteApp::ui`, in the gap around the existing
  `painter.galley(...)` call. The tail rect is computed *before* that call (because `painter.galley`
  takes `Arc<Galley>` by value and moves it): `galley.pos_from_cursor(galley.end())` gives a
  zero-width galley-local rect, offset by the galley paint origin `pos2(x, y)`. Width is
  `0.5 * FONT_SIZE`; height is `tail.height().max(FONT_SIZE)` (floored so it's never degenerate). The
  rect is only computed when `self.visible` is non-empty, so nothing draws on a blank/cleared/
  fully-faded screen. After the galley move, the rect is painted with `painter.rect_filled` using
  `body.gamma_multiply(factor)`, where `factor = glyph_factor(g.birth, now, self.visibility_head)`
  for the newest visible glyph (`self.visible.back()`) — so the cursor fades in lockstep with the
  text it sits against and inherits `darker` mode via `body`.

Because `x` already folds in the shake `dx`, the marker rides the backspace-limit wiggle for free.
No new struct fields, no changes to `render.rs`/`main.rs`/the event loop, and no repaint-loop change
(the render loop already requests continuous repaints while `visible` is non-empty). Consistent with
the GUI-only precedent, no new unit tests were added (this is pure paint with no testable logic);
positioning was confirmed by manual acceptance. `cargo fmt --check`, `cargo clippy --all-targets`,
and `cargo nextest run` (13 tests) all clean.

## `--edit` flag — open `$EDITOR` on today's file

Added the program's first CLI flag, `--edit`, an escape hatch to revisit and freely edit
today's session file outside the fading TUI.

- `src/app.rs` — extracted the inline date/dir path logic out of `open_session_file()` into a
  reusable `pub fn today_file_path()` (resolves `~/Documents/write/YYYY-MM-DD.md`, `create_dir_all`
  on `write/`, no open/lock); `open_session_file()` now calls it. Added `pub fn run_edit()`: touches
  the file so the editor opens an existing path, resolves the editor via `$VISUAL``$EDITOR``vi`,
  whitespace-splits the command (so `"code -w"` works), spawns with inherited stdio, waits, and
  propagates a non-zero editor exit code.
- `src/main.rs` — before acquiring the session file/lock, if `--edit` is in args, runs `run_edit()`
  and returns; never builds `NativeOptions`, never runs `run_native`, never takes the lock.

Dependency-free arg matching via `std::env::args()`. Manually verified: `EDITOR=true ... --edit`
exits 0, `EDITOR=false ... --edit` exits 1; plain `cargo run` launches the TUI unchanged.
`cargo fmt --check`, `cargo clippy --all-targets`, and `cargo nextest run` (13 tests) all clean.

## Session timestamp header — `## <datetime>` at session start

Each real writing session now stamps today's append-only file with a Markdown header
`## <datetime>` so sessions are timestamped and visually delimited within the day's file.

- `src/app.rs` — in the `Ok(())` lock arm of `open_session_file()`: bind `existing =
  fs::read_to_string(&path)` once, compute `seed = word_count(&existing)` **before** writing the
  header (so the header's tokens aren't counted as the writer's), then write the header to the raw
  append-mode fd **before** capturing `boot_size`. Capturing `boot_size` after the write folds the
  header into the hard delete floor, so the existing floor mechanism protects it from backspace for
  free, and the empty in-memory `document` mirror means it is never rendered or faded on screen.
- Idempotent blank-line separation: the newline prefix caps at exactly two trailing newlines
  (`""` for empty or already-`"\n\n"`-terminated, `"\n"` for one trailing newline, else `"\n\n"`),
  so repeated same-day launches don't grow blank-line runs.
- Format `%Y-%m-%d %-I:%M:%S%P %Z` → e.g. `## 2026-06-26 2:30:05pm PDT`. `--edit` does not call
  `open_session_file()`, so editing in `$EDITOR` injects no header.

Manual verification (GUI task), consistent with prior color/cursor work. `cargo clippy`,
`cargo fmt --check`, and `cargo nextest run` (13 tests) all clean. Known follow-up filed in PLAN.md:
prior-session headers inflate later sessions' day-total word count by ~4 tokens each until
`word_count` learns to skip ATX `## ` header lines.

## Header-aware word counting — skip `## ` ATX-header lines in `word_count`

`word_count` (`src/app/render.rs`) now drops ATX level-2 header lines (first non-space content
`## `) before tallying whitespace tokens. Session timestamp headers (`## <datetime>`, written by
`open_session_file`) previously inflated the writer's day-total by ~4 words per prior session,
because `seed = word_count(&existing)` ran over earlier sessions' header lines. The filter is
applied centrally in `word_count`, so the HUD, the Esc menu, and the on-disk seed stay consistent
and exclude both prior- and current-session headers. Level-3+ headers (`### …`) are unaffected.
Added `word_count_skips_atx_headers` unit test; `cargo fmt`, `cargo clippy`, and `cargo nextest run`
(14 tests) all clean.

## Invert the color dependency — primary green, older text a darker green

The text-color model now runs **primary → older** instead of primary → fresh. The light green is
the primary color: freshly-typed (still-backspaceable) text renders at `PRIMARY = rgb(154, 220,
154)` directly, and older/locked text is *derived* as a uniformly darkened shade via
`darken(primary)` (`OLDER_SCALE = 0.78` → `rgb(120, 171, 120)`), preserving the green hue. No pure
white anywhere; aging text now transitions green → darker-green, then still fades to black over
`FADE_SECS` on the premultiplied `gamma_multiply` path.

- `src/app/render.rs` — replaced the stale `redden`/`FRESH_SCALE` machinery (which mis-named green
  as "red") with `PRIMARY`, `PRIMARY_DARKER = rgb(21, 30, 21)`, `OLDER_SCALE`, and a `darken` fn
  that scales all three channels. In `build_job`, renamed the `base` param to `primary` and inverted
  the per-run choice to `if fresh { primary } else { darken(primary) }`.
- `src/app.rs` — replaced the near-white `body` computation with
  `let primary = if self.darker { PRIMARY_DARKER } else { PRIMARY };`, renamed `body``primary`
  at the `build_job` arg, the `painter.galley` fallback, and the cursor color; imported `PRIMARY`
  and `PRIMARY_DARKER` (`darken` stays internal to `render.rs`).

Darker mode now reads as a dim green (primary `rgb(21, 30, 21)`, older `≈ rgb(16, 23, 16)`) rather
than gray. Manual verification (GUI task); no test asserts RGB. `cargo fmt --check`, `cargo clippy`,
and `cargo nextest run` (14 tests) all clean.

## clap-based CLI — `edit` and `show` subcommands (replacing `--edit`)

Replaced the hand-rolled `std::env::args().skip(1).any(|a| a == "--edit")` check with a real
[`clap`](https://docs.rs/clap) derive CLI, and restructured the non-default modes as subcommands.
`write` (no args) launches the fading TUI exactly as before; subcommands run without taking the
advisory lock or flashing a window.

- `Cargo.toml` — added `clap = { version = "4", features = ["derive"] }` via
  `cargo add clap --features derive` (clap 4.6.1).
- `src/app.rs` — added `pub fn run_show()` next to `run_edit()`: resolves `today_file_path()` and
  `fs::read``stdout().write_all` (byte-verbatim, not `read_to_string`/`println!`, so headers and
  trailing-newline state are preserved). A missing file (`ErrorKind::NotFound`) is `Ok(())` with no
  output. Creates nothing and takes no lock.
- `src/main.rs` — added a clap `Cli`/`Command` derive (`#[command(version, about)]`) with an
  optional subcommand. `main()` parses and dispatches `Edit`/`Show` before any lock/window work
  (each returns `Ok(())` or `std::process::exit` to satisfy the `eframe::Result` return type);
  `None` falls through to the unchanged `open_session_file()``run_native` path. The `--edit`
  flag is **removed**`write --edit` now errors via clap (exit 2).
- `README.md` — documented `write edit` / `write show` and that a bare `write` launches the TUI.
- `CHANGELOG.md` — added an `[Unreleased]` section (clap CLI, `edit`/`show` subcommands, `--edit`
  removed).

Verified: `--help` lists `edit`/`show`, `--edit` errors with exit 2, `write show` is byte-identical
to `cat ~/Documents/write/$(date +%F).md` and prints nothing/exit 0 with no file. `cargo fmt`,
`cargo clippy --all-targets`, `cargo build`, and `cargo nextest run` (14 tests) all clean.

## Add `\` (backslash) menu leader key plus a `\\` literal-backslash action

Made `\` a second way to open the menu (alongside `Esc`), giving a leader-style command prefix
(`\q` quit, `\w` windowed/fullscreen, `\d` darker mode), and added a menu action that inserts a
single literal backslash so the new leader key remains typeable as text.

- `src/app.rs` — the only behavioral change:
  - Writing-mode key match: `egui::Key::Escape | egui::Key::Backslash` now opens the menu (still
    `self.fsync()` on open). The trailing `Text("\\")` from the opening keypress is swallowed by
    the existing `if self.menu_open { ... continue; }` gate, so the opening `\` never leaks.
  - Menu-open key match: new `egui::Key::Backslash` arm appends one `"\\"`, resets `enter_run = 0`
    (matching the normal Text-append path), and sets the deferred `close_menu = true`. Because the
    close is deferred, `menu_open` stays true through the rest of the batch and the trailing
    `Text("\\")` is swallowed — exactly one backslash is inserted.
  - `egui::Key::Escape => close_menu = true` retained as the in-menu resume key.
  - Menu hint label gained `     \ — backslash`.
  - Struct-field comments reworded from "toggled from the Esc menu (W)/(D)" to "toggled from the
    menu (W)/(D)".
- `src/app/render.rs``word_count` doc comment "the HUD and the Esc menu" → "the HUD and the menu".
- `README.md` — keybinding table row now shows `` `\` `` / `` `Esc` `` as openers and documents the
  literal-backslash action.
- `CHANGELOG.md` — added an `[Unreleased]` section above the released `0.4.0` describing the new
  leader key and `\\` action.

Verified: `cargo fmt`, `cargo clippy --all-targets`, `cargo build`, and `cargo nextest run`
(14 tests) all clean. Event-handling behavior is not unit-testable without a harness (the suite
covers pure helpers only); it relies on egui's established `Key`-before-`Text` ordering, the same
assumption already underpinning the `W`/`D` menu keys.

## Menu layout — larger font + two-column command/keybinding table

Replaced the in-app menu's single run-on hint line with a tinted two-column table.

- `src/app.rs` (menu block, `if self.menu_open { egui::Window::new("menu") ... }`):
  - Header `"{words} words"` now tinted `PRIMARY` (green) via `RichText::new(...).color(PRIMARY)`;
    size unchanged at `24.0`.
  - The run-on `win_hint`/`dark_hint` label is gone. Commands now render as an
    `egui::Grid::new("menu_commands")` two-column table: command name (left), key binding
    (right-aligned via `Layout::right_to_left`). Rows: quit/Q, resume/Esc, fullscreen-or-windowed/W,
    darker-or-normal/D, backslash/\.
  - Command/key rows bumped 25% to `.size(17.5)` (14 × 1.25) and tinted `PRIMARY`.
  - State-aware rows: `win_cmd`/`dark_cmd` carry the flipping word (windowed↔fullscreen,
    normal↔darker) in the command column; the key column is the static `W`/`D`.

Presentation-only change — no behavior/state changes. `PRIMARY` was already imported in `app.rs`.

Verified: `cargo fmt`, `cargo clippy --all-targets`, `cargo build`, and `cargo nextest run`
(14 tests) all clean. Menu rendering is not unit-testable without an egui harness; layout
correctness confirmed by manual smoke test only (the suite covers pure helpers).

## Debounce the backspace-blocked wiggle

A blocked backspace at the delete floor triggers the horizontal "no" wiggle via
`shake_start`. Previously this fired on *every* blocked press, so holding/mashing
backspace at the floor restarted the animation each frame and it never decayed.

- `src/app/render.rs` — added pure predicate `shake_ready(now, shake_start) -> bool`
  next to `shake_offset`/`SHAKE_SECS`: returns `now - shake_start >= SHAKE_SECS`. The
  `f64::NEG_INFINITY` sentinel makes the very first wiggle always fire.
- `src/app.rs` — added `fn start_shake(&mut self, now)` that sets `shake_start` only
  when `shake_ready` is true; routed both blocked branches in `backspace()` through it
  instead of assigning `shake_start` directly. Imported `shake_ready` from `render`.
- Boundary is `>=`, matching `shake_offset`'s `since >= SHAKE_SECS` rest condition, so a
  fresh wiggle can fire exactly when the prior one reaches rest. Successful deletions are
  unaffected (they never call `start_shake`).

Verified: `cargo clippy --all-targets` and `cargo nextest run` (15 tests, including the
new `shake_ready_debounces_until_completion`) all clean.